前阵子行政提了个需求:能不能在网页里做个考勤打卡,员工进园区后在地图上能看到公司范围,点一下按钮完成签到,别再让大伙儿装 App。我第一反应是这活儿 Vue3 + Leaflet 地图库加 Leaflet Draw 插件就能干,而且能干净利落地收尾。为什么不用高德百度 SDK?申请 key、企业认证、商用合规流程走下来,这需求都能拖黄了。Leaflet 免费开源、插件生态成熟,Draw 插件专门解决画围栏的问题,配合 Vue3 的组合式 API,一套前后端分离的考勤打卡功能,两周内就能上线。
这篇文章不吹概念,直接按我的实现路径走:技术选型、环境搭建、围栏绘制、打卡判断、踩坑实录、部署扩展,每一段都有代码和思路,照着抄能少走不少弯路。
1. 从考勤需求到 Leaflet 选型:为什么我不选高德地图 SDK
1.1 先拆一遍需求,别急着写代码
地图考勤打卡看上去简单,但细节拆开能列一长串:
- 员工进入考勤页面,页面需要自动定位并展示当前位置
- 管理员需要能在地图上划出公司考勤范围(围栏),这个范围要能随时调整
- 员工打卡时,系统判断当前坐标是否落在围栏内,给出打卡成功或失败的结果
- 打卡记录要包含时间、位置、打卡结果,最好还能留一张地图快照当"证据"
这类需求最大的难点不在定位,而在"围栏"这个动态概念上——公司的办公区域不是永远固定,临时办公点、外勤门店都可能成为打卡范围。如果围栏坐标写死在代码里,每次调整都要发版,这在工程上不可接受。所以围栏必须做成可配置的数据:管理员在地图上画一次,坐标存到后端,员工端每次拉取最新围栏做匹配。这一下子就把技术方案圈定了:需要一个能自由绘制多边形的 Web 地图库,Leaflet 加 Leaflet Draw 几乎是这个场景下的最优解。
1.2 Leaflet 与高德/百度 SDK 的取舍
我在这类需求上做过对比,简单列个表:
| 方案 | 定位与底图 | 绘制围栏能力 | 商用成本 | 坑位 |
|---|---|---|---|---|
| 高德 JS API | 国内坐标体系 GCJ-02,定位接口好用 | 官方没有完整 Draw 组件,得自己封装 | 需要 key 和企业认证,商用要合规审 | 坐标系与 Leaflet 不通用,后期换库成本高 |
| 百度 JS API | GCJ-02 进一步偏移成 BD-09 | 同样没有现成绘制插件 | 同上 | 底图风格偏旧,WebGL 版本调试麻烦 |
| Leaflet + OSM/第三方瓦片 | 标准 WGS84,定位走后端/浏览器 | Leaflet Draw 完成度极高,开箱即用 | 完全免费,无 key 依赖 | 国内底图加载速度要挑瓦片源 |
选 Leaflet 的根本原因不是"免费"两个字,而是它把"绘制多边形"这个核心能力交付得最完整。Leaflet Draw 支持画矩形、多边形、圆形、折线、标记点,还能对已画图形做编辑和删除,这套交互在高德百度生态里没有现成平替,自己写一个拖拽节点、橡皮筋绘制的组件,工程量是考勤功能本身的好几倍。
底图问题也有解决办法:Leaflet 不限制瓦片源,我在国内项目里挂的是高德瓦片或者天地图瓦片,配合坐标系转换就能用。考勤范围的方圆几公里,瓦片偏差完全在可接受范围内,后文我会讲到坐标系的处理。
1.3 Vue3 组合式 API 与 Leaflet 实例的相处之道
Vue3 和 Leaflet 没有官方的 binding 库,不像 vue2-leaflet 那套老方案,但这种"非绑定"反而更省心,因为 Leaflet 本身就是面向命令式操作设计的:你创建一个 map 实例,往上面 addLayer、addControl,它内部有自己完整的事件循环和渲染生命周期,根本不需要 Vue 去管它的状态。
真正要注意的是:不要把一个 Leaflet 实例塞进 Vue3 的 reactive 响应式对象里。
Leaflet 内部对象上有大量方法、事件处理器和 DOM 引用,如果用 reactive() 包住 map,Vue3 的 Proxy 会深度代理这些属性,每次 Leaflet 内部读写属性都会触发 Proxy 的 get/set 拦截。正常跑还能工作,一旦地图图层多了、marker 多了,性能下降非常明显,而且偶尔会出现"地图拖不动""点击没反应"这种玄学问题。正确姿势是:
- map 实例用普通
let变量或shallowRef保存 - 围栏图层集合 drewItems 用普通变量保存
- 只有"当前打卡状态""围栏坐标数组"这类真正的业务数据才放进 reactive/ref
生命周期上,onMounted里初始化地图,onBeforeUnmount里调用map.remove()销毁实例并释放事件监听,这是 Vue3 应用里 Leaflet 不会残留内存的底线。
2. 项目脚手架与 Leaflet 基础环境搭建
2.1 用 Vite 创建一个 Vue3 + TS 项目
考勤打卡这种中后台功能,我习惯直接用 Vite 的 Vue + TypeScript 模板开始,省去后续补类型的麻烦。
npm create vite@latest attendance-map -- --template vue-ts cd attendance-map npm installNode 版本建议 18 以上,Vite 5 对 Node 的版本要求是 18+,如果你还在用 16 的旧环境,多半会在 dev server 启动时报错。装完依赖后先npm run dev确认默认页面跑起来,再继续加东西。
2.2 安装 Leaflet 与 Leaflet Draw,以及类型兜底
npm install leaflet leaflet-draw npm install -D @types/leaflet这里有一个新手经常卡住的问题:@types/leaflet-draw这个包存在,但早已不维护,和最新版leaflet-draw的类型对齐度很差。强行安装后,你会在引用L.Control.Draw时看到一堆类型报错。
我实际项目里的做法是:不装@types/leaflet-draw,在src/types/leaflet-draw.d.ts里自己补一个最小声明:
declare module 'leaflet-draw' { const Draw: any export default Draw }然后在用L.Control.Draw的地方统一(L.Control as any).Draw或直接any断言。Leaflet 核心库的类型保留,Draw 插件的类型自己兜底,这样既不会污染核心类型,也能顺利编译。
2.3 初始化地图:核心组件代码
地图容器是整页的,我直接写一个MapContainer.vue组件:
<template> <div ref="mapRef" class="map-container"></div> </template> <script setup lang="ts"> import { onMounted, onBeforeUnmount, ref } from 'vue' import L from 'leaflet' import 'leaflet/dist/leaflet.css' import 'leaflet-draw/dist/leaflet.draw.css' import 'leaflet-draw' const mapRef = ref<HTMLDivElement | null>(null) let map: L.Map onMounted(() => { map = L.map(mapRef.value!, { center: [30.657, 104.065], // 以目标园区为中心 zoom: 16, zoomControl: true, }) L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(map) }) onBeforeUnmount(() => { if (map) { map.remove() } }) </script> <style scoped> .map-container { width: 100%; height: 100vh; } </style>注意两点:
leaflet.draw.css必须引入,否则绘制工具栏的图标、按钮样式全部丢失,画完多边形后右上角那个"完成保存"的小勾也看不见。- 瓦片源我用的是 OSM,国内访问速度不稳定,生产环境建议换成国内 CDN 的瓦片(比如高德瓦片
https://webrd0{1-4}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z})或天地图瓦片,感官上快得多。
2.4 图标丢失与样式引入的"新手三连"
这个坑基本每个 Leaflet 新手都会踩一次:默认的 marker 图标在构建后变成一片空白,控制台报找不到marker-icon.png。原因是 Leaflet 的 CSS 里用相对路径引用了图片资源,Vite 打包时不会自动把这些图片复制到正确位置。
解决办法是手动指定图标资源:
import L from 'leaflet' import iconUrl from 'leaflet/dist/images/marker-icon.png' import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png' import shadowUrl from 'leaflet/dist/images/marker-shadow.png' delete (L.Icon.Default.prototype as any)._getIconUrl L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl, })这一段最好放在main.ts或地图模块的顶部执行,确保后续所有 marker 都能正常显示。如果你不打算用默认 marker,而是用自定义的 divIcon,那这个坑可以不填。
另一个容易忽略的是map.invalidateSize()。凡是地图初始化时容器还处于隐藏状态(比如在 Tab 弹窗、折叠面板里),初始化完成后地图区域经常是灰的或偏移的。等到容器真正可见了,必须手动调用:
nextTick(() => { map.invalidateSize() })invalidateSize会重新计算地图的容器尺寸和中心点,这是"地图渲染不出来"类问题的第一排查项。
3. Leaflet Draw 围栏绘制:从"画个圈"到拿到可用坐标数据
3.1 把围栏设计成数据,而不是写死在代码里
考勤围栏如果只是固定的一个园区,当然可以直接把经纬度写死。但现实中,很多公司是流动式的:总部、分部、临时展会、驻场项目地,每隔一段时间围栏就会变。所以我在设计时把围栏拆成了两个角色:
- 管理员端:进入"围栏管理"模式,地图上显示 Draw 控件,画完保存,坐标发给后端
- 员工端:只加载后端最新的围栏并展示,只读,不显示 Draw 控件
这样一套地图代码,两种模式,一份数据。围栏本质上就是一个多边形坐标数组:
[ { "lat": 30.657, "lng": 104.065 }, { "lat": 30.658, "lng": 104.068 }, { "lat": 30.656, "lng": 104.069 } ]存储和传输都简单,前端拿到数据后用L.polygon()画出来即可。
3.2 启用 Draw 控件之前的配置细节
在 Vue3 里给地图加 Draw 控件,代码不复杂,难在知道要配置哪些选项。一个典型的初始化:
import L from 'leaflet' import 'leaflet-draw' const drawnItems = L.featureGroup().addTo(map) const drawControl = new (L.Control as any).Draw({ position: 'topright', draw: { polygon: { allowIntersection: false, // 禁止画出自相交多边形 showArea: true, // 绘制时显示面积 shapeOptions: { color: '#1888ff', }, }, polyline: false, circle: false, rectangle: { shapeOptions: { color: '#1888ff', }, }, marker: false, circlemarker: false, }, edit: { featureGroup: drawnItems, remove: true, }, }) map.addControl(drawControl)这里我把circle、marker、polyline都关掉了,业务上考勤围栏只需要多边形和矩形(矩形本质是四点多边形)。少暴露入口,管理员学习成本就低,误操作也少。
allowIntersection: false这个选项非常推荐打开,它能防止管理员画出一个"蝴蝶结"式的自相交多边形,这种多边形在做点包含判断时结果不可预期。
featureGroup是 Draw 插件管理的图层组,所有画出来的图形都会自动加进去,编辑和删除也基于它,所以它和 Draw 控件是一对一绑定的。
3.3 从 draw:created 事件中提取经纬度
每次画完一个图形,Draw 插件会触发draw:created事件,事件对象e.layer就是刚画的图形对象。这里关键点在于:不同的图形类型,获取坐标的方式不同。
map.on(L.Draw.Event.CREATED, (e: any) => { const layer = e.layer // 如果是 Polygon 或 Rectangle if (layer instanceof L.Polygon) { const latlngs = layer.getLatLngs() // latlngs 是嵌套数组,[0] 是外环 const outerRing = latlngs[0] as L.LatLng[] const fencePoints = outerRing.map((latlng) => ({ lat: latlng.lat, lng: latlng.lng, })) // 这里把 fencePoints 发给后端或保存到本地 } // 加入 drawnItems,保证后续编辑/删除能被跟踪 drawnItems.addLayer(layer) })我最初踩过一个坑:直接用layer.getLatLngs()的完整返回值去存,结果发现它是一个多层嵌套数组。比如多边形坐标在[0][0]、[0][1]这种索引上,多边形的洞在更深层。如果不加[0]直接扁平化处理,保存的围栏数据要么为空,要么顺序错乱。所以对 Polygon 取latlngs[0]作为外环,是判断围栏的基础。
矩形稍微有点特殊,L.Rectangle继承自L.Polygon,getLatLngs()返回也是外环数组,四个点,顺时针/逆时针不一定,但点在多边形判断不关心方向,所以可以直接当 Polygon 处理。
3.4 编辑、删除事件与坐标回填
光能画还不够,考勤围栏隔几个月要改一次边界。Draw 插件自带的编辑能力是:点一下图形左上角的铅笔图标,进入节点编辑模式,拖拽节点改变形状;点垃圾桶图标删除图形。
但是要注意,编辑完成之后触发的不是draw:created,而是draw:edited和draw:deleted。如果只监听created,你会发现在地图上改了围栏半天,后端存的数据还是老版本。
map.on(L.Draw.Event.EDITED, (e: any) => { const layers = e.layers layers.eachLayer((layer: any) => { if (layer instanceof L.Polygon) { const latlngs = layer.getLatLngs() const outerRing = latlngs[0] as L.LatLng[] const fencePoints = outerRing.map((latlng) => ({ lat: latlng.lat, lng: latlng.lng, })) // 保存最新 fencePoints } }) }) map.on(L.Draw.Event.DELETED, () => { // 清空围栏数据,或置空提示 })还有一个体验细节:保存成功之后,最好做一个状态标识,避免管理员画完忘记保存就退出页面。我通常在draw:created之后,把页面右上角"保存围栏"按钮置亮,点击后才真正把数据 POST 到后端。
4. 打卡判断与数据闭环:定位、围栏校验、结果快照
4.1 浏览器定位:能用但有前提
员工端的地图没有那么复杂:进入页面,全屏地图显示当前位置,下方一个"打卡"按钮。定位我优先用浏览器原生的navigator.geolocation,不需要额外引入 SDK:
function getCurrentPosition(): Promise<{ lat: number; lng: number }> { return new Promise((resolve, reject) => { if (!navigator.geolocation) { reject(new Error('当前浏览器不支持定位')) return } navigator.geolocation.getCurrentPosition( (pos) => { resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude, }) }, (err) => { reject(err) }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0, } ) }) }三个选项值得说明一下:
enableHighAccuracy: true在手机上会调用 GPS,在城市里定位精度能到十几米,比基站定位准得多timeout: 10000防止定位一直转圈不出结果maximumAge: 0每次都重新定位,避免拿到很久之前的缓存位置往围栏里判
但有个硬前提:Chrome 等现代浏览器只在HTTPS或localhost环境下才开放 Geolocation API。如果是公司内网 HTTP 部署,这个接口会直接失败。我在上线前踩过这个坑,页面在本地npm run dev一切正常,部署到内网就提示定位失败,排查半天才发现是浏览器安全策略。生产环境要么上 HTTPS,要么适配其他定位途径。
如果公司在国内,也可以换高德 JS API 的定位插件,它用的是高德服务器 + IP 定位 + 辅助 GPS,兼容性更好,但问题是它是 GCJ-02 坐标,和 Leaflet 默认 WGS84 不匹配,需要做坐标转换,这涉及到加密坐标的偏移算法。我的建议是能上 HTTPS 就用浏览器定位,省心又准。如果必须要用国内定位服务,再考虑引入高德 SDK 并用coordtransform之类的库转坐标。
4.2 围栏内的几何判断:用 Turf.js 一锤定音
拿到管理员画的围栏坐标数组和员工当前位置后,剩下来最核心的就是回答一个问题:当前坐标是否在多边形内部?
Leaflet 本身没有提供"点在多边形内"的判断方法,但空间分析的活 Turf.js 是专业选手。安装:
npm install @turf/turf然后判断:
import { booleanPointInPolygon, point, polygon } from '@turf/turf' const fencePoints = [...围栏坐标数组] const fencePolygon = polygon([fencePoints.map((p) => [p.lng, p.lat])]) const userPoint = point([currentLng, currentLat]) const isInside = booleanPointInPolygon(userPoint, fencePolygon) if (isInside) { // 打卡成功 } else { // 打卡失败,提示员工进入围栏范围后再试 }这里有个小细节:polygon()方法接收的坐标是[经度, 纬度]的顺序,而 Leaflet 的L.LatLng是{ lat, lng }的顺序,两个顺序方向相反,我第一次写就直接翻车了,判断结果永远为 false。每次做这种转换,都先写个 map 把{ lat, lng }转成[lng, lat]数组。
4.3 组装打卡接口与状态 UI
完整流程串起来大概是:
- 页面进入,加载围栏并
L.polygon(...).addTo(map) - 自动定位,把当前定位点 marker 加到地图上
- 点击"上班打卡"按钮,拿到当前位置,做 Turf 判断
- 把结果(时间、经纬度、围栏 ID、是否成功)POST 给后端
- 前端根据后端返回显示打卡结果,并生成一条本地记录
打卡的核心方法可以这样写:
async function handleClockIn() { const loading = showLoading() try { const current = await getCurrentPosition() // 在地图上更新一个定位 marker if (currentMarker) { currentMarker.setLatLng([current.lat, current.lng]) } else { currentMarker = L.marker([current.lat, current.lng]).addTo(map) } const isInside = booleanPointInPolygon( point([current.lng, current.lat]), fencePolygon ) if (!isInside) { showToast('不在考勤范围内,请进入园区后再打卡') return } const res = await fetch('/api/attendance/clock-in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lat: current.lat, lng: current.lng, time: Date.now(), fenceId: currentFenceId, }), }) if (res.ok) { showSuccess('打卡成功') // 刷新考勤记录 } } catch (err) { showError('定位失败,请检查浏览器定位权限') } finally { loading.close() } }4.4 打卡快照:保存"现场证据"
考勤打卡经常有争议:员工说"我明明在园区里",HR 说"后台显示没有记录"。为了避免拉扯,我在打卡成功时还做了一步"地图快照"——把地图当前画面截成图片,随打卡记录一起保存。
Leaflet 有leaflet-image插件,可以基于 canvas 把地图导出为 base64 图片,但兼容性一般,尤其在高分屏下质量不稳定。更通用的方案是用html2canvas直接截取地图容器 DOM:
import html2canvas from 'html2canvas' async function captureMapSnapshot() { const mapEl = document.querySelector('.map-container') as HTMLElement const canvas = await html2canvas(mapEl, { useCORS: true, backgroundColor: '#fff', }) const base64 = canvas.toDataURL('image/png') // base64 存入打卡记录 return base64 }但html2canvas对 Leaflet 的 canvas 瓦片层支持也有坑,截出来的图片经常是空白或残缺。最稳妥也不依赖额外插件的办法是:复制当前视图需要的核心数据(围栏坐标、当前位置、时间、打卡结果),在结果弹窗里用纯 DOM 画一张"地图卡片",配合html2canvas去截这个自定义 DOM。这样图片上是干净整洁的考勤信息,而不是一张带有瓦片版权标注的地图。
5. 你会遇到的坑:Leaflet Draw 在 Vue3 下的兼容性排查记录
5.1 最隐蔽的坑:HTTP 协议下的定位静默失败
我在部署阶段被坑得最惨的一次,就是定位在开发环境正常、生产环境直接挂。前面提到过,Chrome 只在安全上下文(HTTPS 或 localhost)里开放 Geolocation API。上了内网 HTTP 环境后,navigator.geolocation.getCurrentPosition的回调既不走success,也不走error,而是压根不执行,控制台也没有明显报错,所有代码好像"死"在了定位那一步。
排查链路是:先在后端打日志看有没有打卡请求进来,发现没有;再看前端有没有报错,发现没有;最后在定位处手动console.log,才发现回调根本没触发。这属于浏览器安全策略的静默失败。解决办法要么生产环境配 HTTPS,要么用高德定位 SDK 绕过去。
5.2 响应式代理干扰 Leaflet 对象
还有一次,地图拖拽卡顿到怀疑人生,用户每拖动一次地图,CPU 直接拉满。排查到最后发现代码里有人把map实例放进了reactive({})里。Vue3 的reactive用 Proxy 深度代理对象,Leaflet 内部有大量高频属性访问,每次都被 Proxy 拦截,性能自然爆炸。
这个问题在团队协作时特别容易埋进来,因为单看代码,const state = reactive({ map: null }); state.map = L.map(...)好像没毛病,但实际用起来性能就是不对。规范做法是:凡涉及 Leaflet 对象(map、featureGroup、marker、layer)的变量,一律用普通let声明,或者用shallowRef,把响应式代理挡在外面。
5.3 draw:created 数据结构不稳定,必须先判型
Draw 插件允许你同时开启多种图形,但有些考勤管理页面为了灵活把 marker、circle 也打开了。这时候draw:created里拿到的e.layer可能是L.Marker、L.Circle、L.Polyline、L.Polygon,它们的取坐标方法完全不同。
我见过同事直接对e.layer.getLatLngs()一把梭,结果在画 marker 的时候直接报getLatLngs is not a function,页面白屏。所以事件里第一行就做类型判断:
map.on(L.Draw.Event.CREATED, (e: any) => { if (!(e.layer instanceof L.Polygon)) { return } // 只有 Polygon 才处理围栏逻辑 })如果业务里允许圆形围栏,另写一个instanceof L.Circle的分支,从getLatLng()和getRadius()拿到圆心和半径,用distance判断打卡距离。
5.4 二次编辑拿不到坐标,问题出在事件类型
这个坑是"改完没保存"最常见的根源。管理员在页面画好围栏,第一次保存成功。后来想调整边界,拖拽节点改了一番,点保存,结果后端围栏还是旧数据。原因就是前端只监听了draw:created,而编辑完成的回调是draw:edited事件。而且draw:edited的e.layers是一个LayerGroup,需要eachLayer遍历才能拿到每个图层。补上监听之后,情况立刻正常。
类似地,删除动作触发draw:deleted,如果删除后没把后端围栏置空,下一次员工端拉取围栏还会拉到旧坐标。
5.5 地图销毁、容器切换与 invalidateSize
在我把考勤功能做成"弹窗内嵌地图"时,遇到了一个烦人的问题:第一次打开弹窗地图渲染正常,关闭弹窗再打开,地图变灰色,控制台一堆报错。原因是我在弹窗关闭时销毁了地图,但 Vue 的弹窗组件复用同一个 DOM 节点,第二次初始化时 Leaflet 发现容器已经被初始化过了,内部状态错乱。
后来改成:弹窗每次打开时先nextTick再初始化地图,关闭时只隐藏弹窗,不销毁地图,或者严格按照onBeforeUnmount中map.remove()的顺序来销毁。如果地图放在 Tab 页签里,切换 Tab 还要在activated钩子里调一次map.invalidateSize(),否则地图容器尺寸变化后,瓦片会留白。
5.6 类型声明缺失的兜底方案
leaflet-draw没有官方类型,@types/leaflet-draw又不维护,那个包里对L.Control.Draw的声明还停留在老版本,和现在的leaflet-draw@1.0.4对不上。硬装类型包的结果就是编译报错多到不想看。我的建议是干脆不装@types/leaflet-draw,自己在src/types下写一个.d.ts文件,把用到的Draw相关对象全部声明为any。宁可牺牲一点类型提示,也不要和过时类型搏斗。
6. 部署与扩展:从单点打卡到企业管理后台
6.1 构建产物与 nginx 部署
Vue3 项目构建很简单:
npm run build产物生成在dist/目录。如果部署到 nginx,并且用了 Vue Router 的 history 模式,需要把非文件请求全部回退到index.html:
server { listen 80; server_name your-domain.com; root /usr/share/nginx/html/attendance; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend-server:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里把/api/单独代理到后端服务,前端打卡请求的fetch('/api/attendance/clock-in')就不需要写死域名,部署时可以随时切换环境。生产环境别忘了配 HTTPS,不然浏览器定位会被安全策略挡掉,整个考勤功能废掉一半。
6.2 后端接口设计的原则
后端接口设计相对简单,我贴一个最小可用的设计思路:
| 接口 | 方法 | 说明 |
|---|---|---|
/api/fence | GET | 获取当前生效的围栏列表 |
/api/fence | POST | 保存/更新围栏坐标 |
/api/attendance/clock-in | POST | 上班打卡 |
/api/attendance/clock-out | POST | 下班打卡 |
/api/attendance/records | GET | 查询打卡记录 |
打卡记录表的核心字段:id、user_id、type(in/out)、lat、lng、result(success/fail)、fence_id、create_time、snapshot_url。
其中snapshot_url存储打卡时前端上传的地图快照图片地址。后端收到前端上传的 base64 图片后,转存到对象存储或服务器本地,返回一个图片 URL。
6.3 后续扩展方向
考勤打卡做完后,我发现这个"Leaflet + Draw 绘制围栏"的组合,可以延伸出好几个实用功能:
- 多个围栏管理:把
Fence做成独立表,支持一个企业有多个考勤范围,员工在不同办公区打卡时自动匹配对应围栏,操作和"员工-围栏"的关联关系绑定 - 考勤报表:根据已存的打卡记录,按天/周/月统计迟到、缺卡、正常出勤,前端用 ECharts 画柱状图和热力图
- 移动端适配:当前页面是全屏地图,手机上 Leaflet 的触摸缩放体验尚可,但 Draw 控件在窄屏下不好操作。可以做一个快捷键:管理员在 PC 上画围栏,员工在手机上只做打卡
- 离线路网点缓存:用 Leaflet 的瓦片缓存策略,把常用区域的瓦片提前缓存到 IndexedDB,员工在园区网络差时也能看到地图
至于和 Vue3 后台管理系统的融合,目前主流的若依、jeecgboot 这类框架的前端都兼容 Vue3,把考勤页面作为子路由嵌进去即可。但要注意这些框架自带的页面布局可能影响地图容器尺寸,进入页面时务必调用map.invalidateSize(),不然地图渲染区域会错位。
回到最初的需求,一个"网页考勤打卡"最终落地形态是:员工手机浏览器打开页面,自动定位,看到自己所在的园区围栏,点一下按钮,打卡成功;HR 在后台随时调整围栏范围,查看每日打卡记录。整个链路里,Leaflet 和 Leaflet Draw 承担了最重的两个部分——地图展示与围栏绘制,Vue3 则把它们干净地集成进了业务框架。地图库本身并不难,难的是把定位、绘制、判断、存储这条数据链打通,并且提前避开浏览器安全策略和响应式代理这些暗坑。希望这篇记录能让你在实现同样的功能时少走点弯路。