文章同步更新于我的个人博客:松果猿的博客,欢迎访问获取更多技术分享。
同时,您也可以关注我的微信公众号:松果猿的代码工坊,获取最新文章推送和编程技巧。
前言
上期文章我们实现了绘制功能和高亮弹窗显示,现在我们来开发地点标注功能
前端界面搭建
大致想一下UI界面:
好吧有点简陋,不过无所谓了,切图仔开工!
将上图的@/components/MapControl.vue
中的菜单栏对象改为下图,上图只是测试效果的假数据,这次我们将地点标注功能放在在这里
新建@/components/Container.vue
,这里标签逻辑部分用的ts语法,写法规范而且方便纠错
vue
<template>
<div class="container">
<div class="input">
<el-input style="width:300px" v-model="inputValue" placeholder="请输入地点标注名称" />
</div>
<div>
<el-button-group>
<el-button style="width:100px" type="primary">保 存</el-button>
<el-button style="width:100px" type="primary">
退 出
</el-button>
</el-button-group>
</div>
<div class="table">
<el-table :data="tableData" style="width: 100%">
<el-table-column label="Id" width="50">
<template #default="scope">
<div style="display: flex; align-items: center">
<el-icon><timer /></el-icon>
<span>{{ scope.row.id }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="Name" width="100">
<template #default="scope">
<div style="display: flex; align-items: center">
<el-icon><timer /></el-icon>
<span >{{ scope.row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="Type" width="80">
<template #default="scope">
<el-popover
effect="light"
trigger="hover"
placement="top"
width="auto"
>
<template #reference>
<el-tag>{{ scope.row.type }}</el-tag>
</template>
</el-popover>
</template>
</el-table-column>
<el-table-column label="Operations" width="150">
<template #default="scope">
<el-button
size="small"
@click="handleEdit(scope.$index, scope.row)"
>
定位
</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(scope.$index, scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const inputValue = ref("");
interface PointLabel {
id: string;
name: string;
type: string;
}
const handleEdit = (index: number, row: PointLabel) => {
console.log(index, row);
};
const handleDelete = (index: number, row: PointLabel) => {
console.log(index, row);
};
const tableData: PointLabel[] = [
{
id: "1",
name: "地点1",
type: "point",
},
{
id: "2",
name: "地点2",
type: "point",
},
{
id: "3",
name: "地点3",
type: "point",
},
];
</script>
<style scoped lang="scss">
.container {
position: absolute;
bottom: 200px;
right: 20px;
width: 400px;
height:350px;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
padding: 20px;
border-radius: 10px;
background-color: #fff;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.1);
}
</style>
引入
界面如下:
再通过组件通信控制显示退出
子组件通过defineEmits
注册一个自定义事件,而后触发emit
去调用该自定义事件,并传递参数给父组件
@/components/MapControl.vue
@/components/Container.vue
现在来写后端
开发后端接口
创建PostgreSQL
数据库和表:这里不再赘述,不然篇幅太长,网上都有教程
sql
sql
CREATE TABLE locations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(255) NOT NULL,
lon DOUBLE PRECISION NOT NULL,
lat DOUBLE PRECISION NOT NULL
);
我之前写过一篇创建springboot初始项目的文章,我们直接把创建好的初始化项目拿过来:
总览一下项目目录结构:
css
src
├── main
│ ├── java
│ │ └── com
│ │ └── beson
│ │ ├── DemoApplication.java
│ │ ├── controller
│ │ └── LocationController.java
│ │ ├── mapper
│ │ │ └── LocationMapper.java
│ │ ├── model
│ │ │ └── Location.java
│ │ └── service
│ │ └── LocationService.java
│ └── resources
│ ├── application.yml
│ ├── mapper
│ │ └── LocationMapper.xml
│ ├── static
└── test
└── java
└── com
└── beson
└── DemoApplicationTests.java
在pom.xml
添加MyBatis和PostgreSQL的依赖:
xml
<!-- Spring Boot Starter MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<!-- PostgreSQL JDBC Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.2.23</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
在application.yml
中配置PostgreSQL数据源:
yaml
# application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/your_database
username: your_username
password: your_password
driver-class-name: org.postgresql.Driver
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.beson.model
创建实体类来映射数据库表:
java
package com.beson.model;
import lombok.Data;
@Data
public class Location {
private Long id;
private String name;
private String type;
private Double lon;
private Double lat;
}
LocationController.java
java
package com.beson.controller;
import com.beson.model.Location;
import com.beson.service.LocationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/locations")
public class LocationController {
@Autowired
private LocationService locationService;
@GetMapping
public List<Location> getAllLocations() {
return locationService.getAllLocations();
}
@GetMapping("/{id}")
public ResponseEntity<Location> getLocationById(@PathVariable Long id) {
Location location = locationService.getLocationById(id);
return location != null ? ResponseEntity.ok(location) : ResponseEntity.notFound().build();
}
@PostMapping
public ResponseEntity<Void> createLocation(@RequestBody Location[] locations) {
for (Location location : locations) {
locationService.createLocation(location);
}
return ResponseEntity.ok().build();
}
@PutMapping("/{id}")
public ResponseEntity<Void> updateLocation(@PathVariable Long id, @RequestBody Location locationDetails) {
Location location = locationService.getLocationById(id);
if (location != null) {
location.setName(locationDetails.getName());
location.setType(locationDetails.getType());
location.setLon(locationDetails.getLon());
location.setLat(locationDetails.getLat());
locationService.updateLocation(location);
return ResponseEntity.ok().build();
}
return ResponseEntity.notFound().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteLocation(@PathVariable Long id) {
locationService.deleteLocation(id);
return ResponseEntity.noContent().build();
}
}
LocationMapper.java
java
@Mapper
public interface LocationMapper {
@Select("SELECT * FROM locations")
List<Location> findAll();
@Select("SELECT * FROM locations WHERE id = #{id}")
Location findById(Long id);
@Insert("INSERT INTO locations (name, type, lon, lat) VALUES (#{name}, #{type}, #{lon}, #{lat})")
@Options(useGeneratedKeys = true, keyProperty = "id")
void insert(Location location);
@Update("UPDATE locations SET name = #{name}, type = #{type}, lon = #{longitude}, lat = #{latitude} WHERE id = #{id}")
void update(Location location);
@Delete("DELETE FROM locations WHERE id = #{id}")
void delete(Long id);
}
LocationService.java
java
package com.beson.service;
import com.beson.mapper.LocationMapper;
import com.beson.model.Location;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class LocationService {
@Autowired
private LocationMapper locationMapper;
public List<Location> getAllLocations() {
return locationMapper.findAll();
}
public Location getLocationById(Long id) {
return locationMapper.findById(id);
}
public void createLocation(Location location) {
locationMapper.insert(location);
}
public void updateLocation(Location location) {
locationMapper.update(location);
}
public void deleteLocation(Long id) {
locationMapper.delete(id);
}
}
连接数据库
全局同源策略配置:
WebConfig.java
java
package com.beson.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
启动成功:
前后端交互
地图逻辑代码不能全部写在OIMap.vue组件里,要不然代码太屎山,我们写一个自定义hooks用于地点标记的相关操作。
不过在此之前,我们需要把map对象存储在pinia中,方便在自定义hooks中调用(一开始我把创建后的map对象存储在pinia里,结果后面写hooks使用时有问题,困恼我好久,然后才知道map的存储有问题,后面换了方式,直接将pinia里的响应式对象作为map对象调用,如下)
将我们之前存储绘制mapStore.js
中添加一个map响应数据
然后将OlMap.vue中的所有map对象改为如下所示:
新建@/stores/markerStore.js
用于管理新增的地点和从后端获取的标记数据
javascript
import { defineStore } from "pinia";
import { transform } from 'ol/proj';
export const useMarkerStore = defineStore("marker", {
state: () => ({
// 新增的标记点
newMarkers: [],
// 新增的单个标记点
newMarker: {
name: "",
lon: "",
lat: "",
type: ""
},
// 标记点数据
markers: [],
}),
actions: {
addMarker() {
const markerCopy = {
name: this.newMarker.name,
lon: this.newMarker.lon,
lat: this.newMarker.lat,
type: this.newMarker.type
};
this.newMarkers.push(markerCopy);
},
setMarkerName(name) {
this.newMarker.name = name;
},
setMarkerType(type) {
this.newMarker.type = type;
},
setMarkerCoordinate(lon, lat) {
const [transformedLon, transformedLat] = transform(
[lon, lat],
'EPSG:3857',
'EPSG:4326'
)
this.newMarker.lon = transformedLon
this.newMarker.lat = transformedLat
},
},
});
新建useMapMarkers.js
用于实现地点标记的相关逻辑
javascript
import { ref } from "vue";
import axios from "axios";
import { transform } from "ol/proj";
import { Point } from "ol/geom";
import { Feature } from "ol";
import { Style, Icon, Text, Fill, Stroke } from "ol/style";
import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";
import markerIcon from "@/assets/img/marker.png";
import { useMarkerStore } from "@/stores/markerStore.js";
import { useMapStore } from "@/stores/mapStore.js";
import { ElMessage } from "element-plus";
import add_markerIcon from "@/assets/img/add_marker.png";
export function useMapMarkers() {
const markerStore = useMarkerStore();
const mapStore = useMapStore();
// 标记图层
const markerLayer = ref(null);
// 初始化标记图层
const initMarkerLayer = () => {
if (!mapStore.map) {
console.error("Map is not initialized");
return;
}
// 初始化标记图层
const vectorSource = new VectorSource();
markerLayer.value = new VectorLayer({
source: vectorSource,
});
mapStore.map.addLayer(markerLayer.value);
};
// 获取标记点数据
const fetchMarkers = async () => {
// 清除之前的图层
if (markerLayer.value) {
mapStore.map.removeLayer(markerLayer.value);
}
initMarkerLayer();
try {
const response = await axios.get("http://localhost:8080/locations");
markerStore.markers = response.data;
renderMarkers();
} catch (err) {
ElMessage.error("获取标记点数据失败:" + err.message);
}
};
// 渲染标记点
const renderMarkers = () => {
if (!markerLayer.value) return;
// 清空标记图层
const source = markerLayer.value.getSource();
source.clear();
// 渲染标记点
markerStore.markers.forEach((marker) => {
const coordinate = transform(
[parseFloat(marker.lon), parseFloat(marker.lat)],
"EPSG:4326",
"EPSG:3857"
);
const feature = new Feature({
geometry: new Point(coordinate),
properties: marker,
});
// 设置标记点样式,包含图标和文本
feature.setStyle(
new Style({
image: new Icon({
width: 30,
src: markerIcon,
anchor: [0.5, 1],
}),
text: new Text({
text: marker.name || "", // 显示地点名称
offsetY: 15, // 文本垂直偏移量
fill: new Fill({
color: "#2D3436", // 深色文本
}),
font: "bold 14px sans-serif", // 加粗字体
stroke: new Stroke({ // 添加文字描边
color: '#FFFFFF',
width: 3
}),
padding: [5, 5, 5, 5], // 文本内边距
textBaseline: 'bottom',
textAlign: 'center'
}),
})
);
source.addFeature(feature);
});
};
// 删除标记点
const removeMarker = async (markerId) => {
try {
const response = await axios.delete(
`http://localhost:8080/locations/${markerId}`
);
if (response.status === 200) {
// 从 store 中移除该标记点
markerStore.markers = markerStore.markers.filter(
(marker) => marker.id !== markerId
);
// 更新图层
if (markerLayer.value) {
mapStore.map.removeLayer(markerLayer.value);
}
await fetchMarkers();
return response;
}
} catch (error) {
throw error;
}
};
//点击事件
const clickEvent = (evt) => {
const coordinate = evt.coordinate;
// 创建新的要素
const feature = new Feature({
geometry: new Point(coordinate),
});
// 设置要素样式
feature.setStyle(
new Style({
image: new Icon({
width: 30,
src: add_markerIcon,
anchor: [0.5, 1],
}),
text: new Text({
text: markerStore.newMarker.name || "", // 显示地点名称
offsetY: 15, // 文本垂直偏移量
fill: new Fill({
color: "#2D3436",
}),
font: "bold 14px sans-serif",
stroke: new Stroke({
color: '#FFFFFF',
width: 3
}),
padding: [5, 5, 5, 5],
textBaseline: 'bottom',
textAlign: 'center'
}),
})
);
// 添加到标记图层
const source = markerLayer.value.getSource();
source.addFeature(feature);
markerStore.setMarkerCoordinate(coordinate[0], coordinate[1]);
markerStore.addMarker();
};
// 添加新标记点的方法
const addMarkers = () => {
initMarkerLayer();
if (!mapStore.map) {
console.error("Map is not initialized");
return;
}
mapStore.map.on("click", clickEvent);
};
// 添加保存标记点的方法
const PostMarker = async () => {
try {
const response = await axios.post(
"http://localhost:8080/locations",
markerStore.newMarkers
);
if (response.status === 200) {
ElMessage.success("标记点添加成功");
markerStore.newMarkers = [];
// 清除临时标记图层并重新获取所有标记点
if (markerLayer.value) {
mapStore.map.removeLayer(markerLayer.value);
}
await fetchMarkers();
}
} catch (error) {
ElMessage.error("添加标记点失败:" + error.message);
}
};
// 获取标记点信息
const getMarkerInfo = (feature) => {
if (!feature) return null;
return feature.get("properties");
};
//取消点击事件
const cancelClick = () => {
mapStore.map.un("click", clickEvent);
};
return {
initMarkerLayer,
fetchMarkers,
PostMarker,
addMarkers,
removeMarker,
getMarkerInfo,
cancelClick,
};
}
在OlMap.vue中挂载钩子中添加如下方法以实现标记图层的渲染
再来对Container.vue进行调整,最终代码如下:
vue
<template>
<div class="container">
<div class="input">
<el-input
style="width: 300px"
v-model="nameValue"
placeholder="请输入地点标注名称"
@change="markerStore.setMarkerName(nameValue)"
/>
</div>
<div class="input">
<el-input
style="width: 300px"
v-model="typeValue"
placeholder="请输入地点类型"
@change="markerStore.setMarkerType(typeValue)"
/>
</div>
<div>
<el-button-group>
<el-button style="width: 100px" type="primary" @click="handleSave"
>保 存</el-button
>
<el-button style="width: 100px" type="primary" @click="handleRefresh">
刷 新
</el-button>
<el-button style="width: 100px" type="primary" @click="handleExit">
退 出
</el-button>
</el-button-group>
</div>
<div class="table">
<el-table :data="tableData" class="table-data">
<el-table-column label="Id" width="50">
<template #default="scope">
<div style="display: flex; align-items: center">
<el-icon><timer /></el-icon>
<span>{{ scope.row.id }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="Name" width="100">
<template #default="scope">
<div style="display: flex; align-items: center">
<el-icon><timer /></el-icon>
<span>{{ scope.row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="Type" width="80">
<template #default="scope">
<el-popover
effect="light"
trigger="hover"
placement="top"
width="auto"
>
<template #reference>
<el-tag>{{ scope.row.type }}</el-tag>
</template>
</el-popover>
</template>
</el-table-column>
<el-table-column label="Operations" width="150">
<template #default="scope">
<el-button
size="small"
@click="handleLocate(scope.$index, scope.row)"
>
定位
</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(scope.$index, scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUpdated } from "vue";
const nameValue = ref("");
const typeValue = ref("");
import { useMarkerStore } from "@/stores/markerStore.js";
import { useMapMarkers } from "@/hooks/useMapMarkers";
import { ElMessage } from "element-plus";
const markerStore = useMarkerStore();
const mapMarkers = useMapMarkers();
interface PointLabel {
id: string;
name: string;
type: string;
}
const handleLocate = (index: number, row: PointLabel) => {
console.log(index, row);
};
const handleDelete = async (index: number, row: PointLabel) => {
try {
await mapMarkers.removeMarker(row.id);
await mapMarkers.fetchMarkers();
tableData.value = markerStore.markers;
ElMessage.success("删除成功");
} catch (error) {
ElMessage.error("删除失败");
}
};
const tableData = ref<PointLabel[]>([]);
onMounted(() => {
mapMarkers.addMarkers();
tableData.value = markerStore.markers;
});
//保存操作
const handleSave = async () => {
try {
await mapMarkers.PostMarker();
await mapMarkers.fetchMarkers();
tableData.value = markerStore.markers;
ElMessage.success("保存成功");
} catch (error) {
ElMessage.error("保存失败");
}
};
//刷新操作
const handleRefresh = async () => {
try {
await mapMarkers.fetchMarkers();
tableData.value = markerStore.markers;
ElMessage.success("刷新成功");
} catch (error) {
ElMessage.error("刷新失败");
}
};
//退出操作
const emit = defineEmits(["visible"]);
const handleExit = () => {
mapMarkers.cancelClick();
emit("visible", false);
console.log("触发退出");
};
</script>
<style scoped lang="scss">
.container {
position: absolute;
bottom: 150px;
right: 20px;
width: 400px;
height: 400px;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
padding: 20px;
border-radius: 10px;
background-color: #fff;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.1);
.table {
width: 100%;
height: 200px;
overflow-y: scroll;
}
}
</style>
这里的定位逻辑还没实现,等到后面再说啦