Leaflet 地图库官方网站:https://leafletjs.cn/index.html
下面是一个后台管理的页面代码,里面包含了一些Leaflet 地图使用
javascript
<template>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" :close-on-click-modal="false"
@close="handleClose">
<div class="dialog-content-wrapper">
<el-form :model="formData" label-position="top" ref="ruleFormRef" :rules="rules">
<!-- 考勤组名称 -->
<el-form-item label="考勤组名称" required prop="name">
<el-input v-model="formData.name" placeholder="请输入用户名" clearable />
</el-form-item>
<!-- 考勤人员 -->
<el-form-item label="考勤人员">
<el-cascader :options="options_data" v-model="formData.userIdList" :show-all-levels="false"
:props="cascaderProps" placeholder="请选择人员" clearable collapse-tags collapse-tags-tooltip
:max-collapse-tags="5" style="width: 100%" filterable />
</el-form-item>
<!-- 考勤班次 -->
<el-form-item label="考勤班次" prop="shiftId">
<el-select v-model="formData.shiftId" placeholder="请选择班次" style="width: 100%" clearable><el-option
v-for="item in shiftOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<!-- 考勤日期 -->
<el-form-item label="考勤日期">
<el-checkbox-group v-model="formData.week">
<el-checkbox :value="1">周一</el-checkbox>
<el-checkbox :value="2">周二</el-checkbox>
<el-checkbox :value="3">周三</el-checkbox>
<el-checkbox :value="4">周四</el-checkbox>
<el-checkbox :value="5">周五</el-checkbox>
<el-checkbox :value="6">周六</el-checkbox>
<el-checkbox :value="7">周日</el-checkbox>
</el-checkbox-group>
</el-form-item>
<!-- 自动排休 -->
<el-form-item label="自动排休">
<el-switch v-model="formData.isLegal" :active-value="1" :inactive-value="0" />
<span style="margin-left: 10px">是否法定假日自动排休</span>
</el-form-item>
<!-- 打卡地点 -->
<el-form-item label="打卡地点" prop="geojson">
<!-- <el-input clearable
v-model="formData.geojson"
placeholder="请输入打卡地点"
style="width: 100%"
/> -->
<el-button type="primary" size="small" @click="openMap">
选择打卡地点
</el-button>
</el-form-item>
<!-- 特殊时间配置 -->
<el-form-item>
<el-collapse style="width: 100%">
<el-collapse-item title="特殊时间配置">
<!-- 必须打卡时间 -->
<div class="time-config-item">
<h4>必须打卡时间:</h4>
<div v-if="formData.checkDate.length === 0" class="empty-tip">
请添加
</div>
<el-button type="primary" size="small" @click="addCheckDate">+ 新增</el-button>
<div v-for="(time, index) in formData.checkDate" :key="index" class="time-item">
<!-- 这里可以根据实际需求添加时间选择器 -->
<el-date-picker clearable v-model="formData.checkDate[index]" format="YYYY/MM/DD"
value-format="YYYY-MM-DD" type="date" placeholder="请选择时间"
style="width: 200px; margin-right: 10px" />
<el-button type="danger" size="small" @click="removeCheckDate(index)">删除</el-button>
</div>
</div>
<!-- 无需打卡时间 -->
<div class="time-config-item">
<h4>无需打卡时间:</h4>
<div v-if="formData.noCheckDate.length === 0" class="empty-tip">
请添加
</div>
<el-button type="primary" size="small" @click="addNoCheckDate">+ 新增</el-button>
<div v-for="(time, index) in formData.noCheckDate" :key="index" class="time-item">
<!-- 这里可以根据实际需求添加时间选择器 -->
<!-- <el-input
v-model="formData.noCheckDate[index]"
placeholder="请选择时间"
style="width: 200px; margin-right: 10px"
/> -->
<el-date-picker clearable v-model="formData.noCheckDate[index]" format="YYYY/MM/DD"
value-format="YYYY-MM-DD" type="date" placeholder="请选择时间"
style="width: 200px; margin-right: 10px" />
<el-button type="danger" size="small" @click="removeNoCheckDate(index)">删除</el-button>
</div>
</div>
</el-collapse-item>
</el-collapse>
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm(ruleFormRef)">确认</el-button>
</span>
</template>
<el-dialog v-model="mapVisible" width="800px" height="600px" title="选择打卡地点" append-to-body show-close="false"
close-on-click-modal="false" :close-on-press-escape="false">
<!-- 用leaflet绘制打卡地点 -->
<div ref="mapContainer" style="width: 100%; height: 600px;"></div>
<div style="margin-top: 10px; text-align: center;">
<el-button size="small" @click="closeMap">关闭地图</el-button>
<el-button type="primary" size="small" @click="saveGeoJson">保存区域</el-button>
</div>
</el-dialog>
</el-dialog>
</template>
<script setup>
import { onMounted, ref, watch, reactive, computed, onUnmounted } from "vue";
import { getTree, getBC } from "@/api/Attendance/Group";
import { ElMessage } from 'element-plus';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet-draw/dist/leaflet.draw.css';
import 'leaflet-draw';
import zhCN from './zn-CN.js';
const ruleFormRef = ref();
const rules = reactive({
name: [{ required: true, message: "请输入考勤组名称", trigger: "blur" }],
shiftId: [{ required: true, message: "请选择考勤班次", trigger: "change" }],
geojson: [{ required: true, message: "请选择打卡区域", trigger: "blur" }],
});
const shiftOptions = ref([]);
const mapVisible = ref(false);
const mapContainer = ref(null);
let map = null;
let drawnItems = null;
let drawControl = null;
const openMap = () => {
// 打开地图选择器
mapVisible.value = true;
// 延迟初始化地图,确保DOM已渲染
setTimeout(() => {
initMap();
}, 100);
};
// 初始化地图
const initMap = () => {
// 如果地图已存在,先销毁
if (map) {
map.remove();
}
// 应用中文语言包
L.drawLocal = zhCN;
// 创建地图实例
map = L.map(mapContainer.value).setView([39.87298, 116.35479], 17); // 默认北京坐标
const defaultZoomControl = map.zoomControl;
// 修改控件配置(仅修改配置,不会自动更新DOM)
defaultZoomControl.options.zoomInText = '放大';
defaultZoomControl.options.zoomOutText = '缩小';
defaultZoomControl.options.zoomInTitle = '点击放大地图';
defaultZoomControl.options.zoomOutTitle = '点击缩小地图';
// 6250b3c72eabffd9b2fc97e8f622c344
// 高德地图普通瓦片
// L.tileLayer('https://webst02.is.autonavi.com/appmaptile?style=7&x={x}&y={y}&z={z}', {
// attribution: '© <a href="https://amap.com">高德地图</a>'
// }).addTo(map);
L.tileLayer('http://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=6250b3c72eabffd9b2fc97e8f622c344', {
attribution: '© <a href="https://www.tianditu.gov.cn/">天地图</a>', // 保留天地图版权信息
maxZoom: 18, // 天地图最大缩放级别(部分图层支持到19)
minZoom: 1,
tileSize: 256 // 瓦片尺寸,固定256
}).addTo(map);
L.tileLayer('http://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=6250b3c72eabffd9b2fc97e8f622c344', {
attribution: '© <a href="https://www.tianditu.gov.cn/">天地图</a>',
maxZoom: 18,
minZoom: 1,
tileSize: 256
}).addTo(map);
// 创建绘制图层组
drawnItems = L.featureGroup().addTo(map);
if (props.editData.geojson) {
// 解析geojson字符串为对象
const geojsonObj = props.editData.geojson;
// 确保是MultiPolygon类型
if (geojsonObj.type === 'Polygon') {
// 提取第一个多边形的坐标
const firstPolygon = geojsonObj.coordinates[0].map(coord => [coord[1], coord[0]]);
// 创建Leaflet多边形
L.polygon(firstPolygon, {
color: '#3388ff', // 显式设置样式,确保可见
fillColor: '#3388ff',
fillOpacity: 0.2
}).addTo(drawnItems);
}
}
// 配置绘制控件
drawControl = new L.Control.Draw({
draw: {
polygon: {
allowIntersection: false, // 不允许交叉
showArea: false, // 显示面积
metric: true, // 使用公制单位
},
marker: false,
circlemarker: false,
polyline: false,
rectangle: false,
circle: false,
},
edit: {
featureGroup: drawnItems,
remove: true
}
});
// 添加绘制控件到地图
map.addControl(drawControl);
// 监听绘制完成事件
map.on(L.Draw.Event.CREATED, (e) => {
const layer = e.layer;
drawnItems.addLayer(layer);
});
};
// 关闭地图
const closeMap = () => {
// 清除绘制的图层
drawnItems.clearLayers();
// 关闭弹窗
mapVisible.value = false;
};
// 保存GeoJSON
const saveGeoJson = () => {
const geoJsonData = drawnItems.toGeoJSON();
if (geoJsonData.features.length === 0) {
ElMessage.warning('请先绘制打卡区域');
return;
}
// 只保存第一个绘制的多边形
const polygonGeoJson = geoJsonData.features[0].geometry;
// 保存到表单
formData.value.geojson = polygonGeoJson;
ElMessage.success('打卡区域已保存');
mapVisible.value = false;
};
// 组件卸载时清理地图
onUnmounted(() => {
if (map) {
map.remove();
}
});
onMounted(() => {
getBC({
week: [1, 2, 3, 4, 5, 6, 7],
isLegal: 0,
checkDate: [],
noCheckDate: [],
}).then((res) => {
shiftOptions.value = res.data.list || [];
});
});
// 定义事件
const emit = defineEmits(["confirm", "close"]);
// 接收props
const props = defineProps({
// 对话框类型:add-添加,edit-编辑
dialogType: {
type: String,
default: "add"
},
// 编辑数据
editData: {
type: Object,
default: () => ({})
}
});
// 弹窗可见性
const dialogVisible = ref(true);
// 动态标题
const dialogTitle = computed(() => {
return props.dialogType === "add" ? "添加考勤组" : "编辑考勤组";
});
// 表单数据
const formData = ref({
name: "", // 考勤组名称
userIdList: [], // 考勤人员
shiftId: "", // 考勤班次
week: [1, 2, 3, 4, 5], // 考勤日期,默认选中周一到周五
isLegal: 1, // 自动排休,1:开启排休,0:关闭排休
geojson: "", // 打卡地点
checkDate: [], // 必须打卡时间
noCheckDate: [], // 无需打卡时间
id: "" // 考勤组ID,编辑时需要
});
// 监听编辑数据变化,初始化表单
watch(() => props.editData, (newData) => {
if (props.dialogType === "edit" && newData) {
// 复制编辑数据到表单,并进行类型转换
formData.value = {
...newData,
// 确保数组类型,使用深拷贝避免修改原数据
userIdList: [...(newData.userIdList || [])],
week: [...(newData.week || [1, 2, 3, 4, 5])],
checkDate: [...(newData.checkDate || [])],
noCheckDate: [...(newData.noCheckDate || [])],
// 保持GeoJSON对象格式
geojson: newData.geojson || "",
// 如果存在shiftInfo,则使用shiftInfo.id作为shiftId
shiftId: newData.shiftInfo?.id || ""
};
// console.log("编辑数据初始化:", formData.value);
}
}, { immediate: true, deep: true });
// 监听地图可见性变化,当地图关闭时清理
watch(() => mapVisible.value, (newVal) => {
if (!newVal && map) {
map.remove();
map = null;
}
});
// 级联选择器配置
const cascaderProps = ref({
multiple: true,
emitPath: false,
checkStrictly: false, // 关闭严格父子节点不关联,开启级联关系
// 关闭懒加载,改为一次性加载所有数据
lazy: false,
// 数据源配置
data: []
});
const options_data = ref([])
// 加载部门和人员数据
const loadUserData = async () => {
try {
// 递归加载所有部门和人员数据
const loadTree = async (deptId = 0) => {
const result = await getTree({ deptId, type: "user" });
const nodes = [];
for (const item of result.data) {
const isUser = item.type === "user";
const node = {
value: item.id,
label: item.name,
leaf: isUser,
type: item.type
};
// 如果是部门,递归加载子节点
if (!isUser) {
node.children = await loadTree(item.id);
}
nodes.push(node);
}
return nodes;
};
// 加载根节点数据
const allData = await loadTree();
options_data.value = allData;
console.log("加载的所有用户数据:", allData);
} catch (err) {
console.error("加载用户数据失败:", err);
options_data.value = [];
}
};
// 组件挂载时加载数据
onMounted(() => {
loadUserData();
getBC({
week: [1, 2, 3, 4, 5, 6, 7],
isLegal: 0,
checkDate: [],
noCheckDate: [],
}).then((res) => {
shiftOptions.value = res.data.list || [];
});
});
// 添加必须打卡时间
const addCheckDate = () => {
formData.value.checkDate.push("");
};
// 删除必须打卡时间
const removeCheckDate = (index) => {
formData.value.checkDate.splice(index, 1);
};
// 添加无需打卡时间
const addNoCheckDate = () => {
formData.value.noCheckDate.push("");
};
// 删除无需打卡时间
const removeNoCheckDate = (index) => {
formData.value.noCheckDate.splice(index, 1);
};
// 确认添加
const handleConfirm = async (formEl) => {
if (!formEl) return;
await formEl.validate((valid, fields) => {
if (valid) {
console.log(formData.value, "-----formData.value--");
emit("confirm", formData.value);
// 重置表单
resetForm();
} else {
console.log("error submit!", fields);
}
});
};
// 关闭弹窗
const handleClose = () => {
emit("close");
dialogVisible.value = false;
// 重置表单
resetForm();
};
// 重置表单
const resetForm = () => {
formData.value = {
name: "", // 考勤组名称
userIdList: [], // 考勤人员
shiftId: "", // 考勤班次
week: [1, 2, 3, 4, 5], // 考勤日期,默认选中周一到周五
isLegal: 1, // 自动排休,1:开启排休,0:关闭排休
geojson: "", // 打卡地点
checkDate: [], // 必须打卡时间
noCheckDate: [], // 无需打卡时间
};
};
</script>
<style lang='scss' scoped>
/* 弹窗内容包裹层样式 */
.dialog-content-wrapper {
max-height: 450px;
overflow-y: auto;
height: 450px;
box-sizing: border-box;
overflow-x: hidden;
padding-right: 5px;
}
/* 重置dialog body样式 */
:deep(.el-dialog__body) {
padding-bottom: 0 !important;
padding-right: 0 !important;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
:deep(.el-checkbox-group) {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* 特殊时间配置样式 */
.time-config-item {
margin-bottom: 20px;
}
.time-config-item h4 {
margin: 0 0 10px 0;
font-size: 14px;
font-weight: bold;
}
.empty-tip {
color: #909399;
margin: 10px 0;
}
.time-item {
margin: 10px 0;
display: flex;
align-items: center;
}
/* 折叠面板样式 */
:deep(.el-collapse-item__header) {
padding: 10px 0;
border-bottom: none;
}
:deep(.el-collapse-item__content) {
padding: 10px 0;
}
</style>
这段代码中打卡地图功能的具体实现方式,核心是基于 Leaflet 地图库结合 Leaflet Draw 插件实现多边形绘制,并将绘制结果以 GeoJSON 格式保存到表单中。
一、核心实现思路
整个打卡地图功能分为 5 个核心步骤:
- 引入地图相关依赖(Leaflet 核心库 + 绘制插件)
- 点击「选择打卡地点」按钮打开地图弹窗并初始化地图
- 配置地图图层(天地图瓦片)和绘制控件(仅允许绘制多边形)
- 监听绘制事件,将用户绘制的多边形保存到图层组
- 点击「保存区域」将绘制结果转为 GeoJSON 格式并赋值给表单
二、关键代码拆解
- 依赖引入(前置条件)
首先需要确保安装了相关依赖(如果是 npm 环境)
javascript
# 安装核心依赖
npm install leaflet leaflet-draw
代码中通过 import 引入核心库和样式:
javascript
import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; // 地图基础样式
import 'leaflet-draw/dist/leaflet.draw.css'; // 绘制插件样式
import 'leaflet-draw'; // 绘制插件核心
- 地图初始化(initMap 函数)
这是地图功能的核心函数,负责创建地图实例、加载图层、配置绘制功能:
javascript
const initMap = () => {
// 1. 销毁已有地图,避免重复创建
if (map) {
map.remove();
}
// 2. 创建地图实例,绑定到 DOM 元素(mapContainer),设置默认中心点和缩放级别
map = L.map(mapContainer.value).setView([39.87298, 116.35479], 17); // 默认北京坐标
// 3. 加载天地图瓦片图层(底图 + 注记层)
// 天地图矢量底图
L.tileLayer('http://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=你的密钥', {
attribution: '© <a href="https://www.tianditu.gov.cn/">天地图</a>',
maxZoom: 18,
minZoom: 1,
tileSize: 256
}).addTo(map);
// 天地图注记层(显示地名)
L.tileLayer('http://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=你的密钥', {
attribution: '© <a href="https://www.tianditu.gov.cn/">天地图</a>',
maxZoom: 18,
minZoom: 1,
tileSize: 256
}).addTo(map);
// 4. 创建绘制图层组(用于存放用户绘制的多边形)
drawnItems = L.featureGroup().addTo(map);
// 5. 编辑模式下,加载已保存的 GeoJSON 并渲染到地图
if (props.editData.geojson) {
const geojsonObj = props.editData.geojson;
if (geojsonObj.type === 'Polygon') {
// 转换坐标格式(Leaflet 是 [纬度, 经度],GeoJSON 是 [经度, 纬度])
const firstPolygon = geojsonObj.coordinates[0].map(coord => [coord[1], coord[0]]);
// 绘制已保存的多边形
L.polygon(firstPolygon, {
color: '#3388ff',
fillColor: '#3388ff',
fillOpacity: 0.2
}).addTo(drawnItems);
}
}
// 6. 配置绘制控件(仅允许绘制多边形,禁用其他类型)
drawControl = new L.Control.Draw({
draw: {
polygon: {
allowIntersection: false, // 不允许多边形交叉
showArea: false,
metric: true,
},
marker: false, // 禁用标记
circlemarker: false,
polyline: false, // 禁用折线
rectangle: false, // 禁用矩形
circle: false, // 禁用圆形
},
edit: {
featureGroup: drawnItems, // 可编辑的图层组
remove: true // 允许删除
}
});
// 7. 添加绘制控件到地图
map.addControl(drawControl);
// 8. 监听绘制完成事件,将绘制的多边形添加到图层组
map.on(L.Draw.Event.CREATED, (e) => {
const layer = e.layer;
drawnItems.addLayer(layer);
});
};
- 核心交互函数
(1)打开地图弹窗
javascript
const openMap = () => {
mapVisible.value = true;
// 延迟初始化地图,确保弹窗 DOM 已渲染
setTimeout(() => {
initMap();
}, 100);
};
(2)保存绘制的区域(转为 GeoJSON)
javascript
const saveGeoJson = () => {
// 1. 将绘制的图层转为 GeoJSON 格式
const geoJsonData = drawnItems.toGeoJSON();
// 2. 校验是否有绘制内容
if (geoJsonData.features.length === 0) {
ElMessage.warning('请先绘制打卡区域');
return;
}
// 3. 只取第一个绘制的多边形
const polygonGeoJson = geoJsonData.features[0].geometry;
// 4. 保存到表单
formData.value.geojson = polygonGeoJson;
ElMessage.success('打卡区域已保存');
mapVisible.value = false;
};
(3)清理地图资源
javascript
// 组件卸载时清理地图,避免内存泄漏
onUnmounted(() => {
if (map) {
map.remove();
}
});
// 关闭地图弹窗时清理图层
const closeMap = () => {
drawnItems.clearLayers();
mapVisible.value = false;
};
三、关键技术点说明
1,坐标格式转换:
- Leaflet 地图使用 [纬度, 经度] 格式
- GeoJSON 标准使用 [经度, 纬度] 格式
- 编辑模式下加载已有数据时,需要执行 coord => [coord[1], coord[0]] 转换
2,天地图瓦片使用
- 代码中使用了天地图的矢量底图(vec)和注记层(cva)
- 需要替换 tk 参数为你自己的天地图密钥(可从天地图官网申请)
3,绘制控件配置
- 仅启用 polygon(多边形)绘制,禁用了 marker、折线、矩形等其他类型
- 支持编辑和删除已绘制的多边形
4,GeoJSON 数据保存
- 最终保存到表单的是纯几何信息(Geometry),格式符合 GeoJSON 标准
- 后端可直接解析该格式进行空间计算
总结
- 核心依赖:基于 Leaflet 地图库 + Leaflet Draw 绘制插件实现多边形绘制
- 核心流程:打开地图弹窗 → 初始化地图(加载天地图瓦片)→ 绘制多边形 → 转为 GeoJSON → 保存到表单
- 关键细节:坐标格式转换(纬度 / 经度 与 经度 / 纬度)、编辑模式下加载已有多边形、组件卸载时清理地图资源避免内存泄漏
这个实现方案的优势是轻量、开源、跨浏览器兼容,且 GeoJSON 是通用的空间数据格式,便于和后端交互。