基于 Vue 3 + 高德地图的网格规划系统实战
手把手教你实现地图区域绘制、网格管理与可视化
源码在第七点
一、前言
在 GIS 系统开发中,网格化管理是一种常见的数据组织方式。本文将带你从零实现一个完整的网格规划系统,包含:
- 🗺️ 高德地图集成与交互
- ✏️ 多边形区域绘制(仿"画图"工具)
- 📋 网格数据的增删改查
- 🎨 可视化网格列表与地图联动
最终效果:用户可在地图上绘制区域,系统自动生成网格并支持编辑管理。

源码在第七点
二、技术栈
| 技术 | 用途 |
|---|---|
| Vue 3 | 前端框架(Composition API) |
| 高德地图 JS API 2.0 | 地图服务与图形绘制 |
| @amap/amap-jsapi-loader | 动态加载高德 SDK |
| HTML5 + CSS3 | 界面与样式 |
三、核心功能实现
1. 地图初始化
使用 AMapLoader 动态加载高德地图 SDK:
javascript
const initMap = async () => {
const AMap = await AMapLoader.load({
key: import.meta.env.VITE_AMAP_KEY,
version: '2.0',
plugins: ['AMap.Scale']
})
mapRef = new AMap.Map('gridMapContainer', {
zoom: 12,
center: [121.473, 31.23],
resizeEnable: true
})
}
关键技术点:
- 容器必须有明确的宽高
resizeEnable: true使地图自适应容器大小- 插件
AMap.Scale提供比例尺控件
2. 交互式区域绘制
这是整个系统最复杂的部分,实现思路:
javascript
// 进入绘制模式
const startDraw = () => {
isDrawing.value = true
mapRef.setStatus({ doubleClickZoom: false }) // 禁用双击缩放
mapRef.setDefaultCursor('crosshair')
// 监听单击添加顶点
mapRef.on('click', (e) => {
drawingPoints.value.push([e.lnglat.getLng(), e.lnglat.getLat()])
refreshTempDraw() // 实时预览
})
// 监听双击完成绘制
mapRef.on('dblclick', () => finishDraw())
}
实时预览的实现:
javascript
const refreshTempDraw = () => {
// 1. 绘制临时折线(≥3个点时闭合)
const pathForLine = pts.length >= 3 ? [...pts, pts[0]] : pts
tempPolyline = new AMapRef.Polyline({ path: pathForLine })
// 2. 绘制顶点标记
pts.forEach((pt, i) => {
const dot = new AMapRef.Marker({
position: pt,
content: i === 0 && pts.length >= 3
? '<div class="green-dot"></div>' // 起点可闭合
: '<div class="blue-dot"></div>'
})
})
}
3. 多边形渲染与数据联动
javascript
const renderPolygons = () => {
gridList.value.forEach(grid => {
// 创建多边形
const polygon = new AMapRef.Polygon({
path: grid.points,
fillColor: grid.color,
fillOpacity: 0.45
})
mapRef.add(polygon)
// 添加中心点标签
const label = new AMapRef.Marker({
position: [centerX, centerY],
content: `<div>网格编码:${grid.code}</div>`
})
})
}
4. 网格列表与地图联动
点击列表项时自动定位到对应网格:
javascript
const handleSelectGrid = (grid) => {
// 计算多边形中心点
const cx = grid.points.reduce((s, p) => s + p[0], 0) / grid.points.length
const cy = grid.points.reduce((s, p) => s + p[1], 0) / grid.points.length
// 移动地图中心
mapRef.setCenter([cx, cy])
}
四、关键技术难点解析
难点1:事件冲突处理
问题:起点的点击闭合事件会同时触发地图的单击添加顶点事件。
解决方案:
javascript
dot.on('click', (e) => {
e.stopPropagation() // 阻止冒泡
e.originEvent?.stopPropagation()
lastClickTime = Date.now() // 重置防抖时间
finishDraw()
})
难点2:双击缩放与绘制完成冲突
问题:地图默认双击会缩放,影响绘制体验。
解决方案:
javascript
// 绘制时禁用
mapRef.setStatus({ doubleClickZoom: false })
// 完成后恢复
mapRef.setStatus({ doubleClickZoom: true })
难点3:弹窗打开时机
问题:双击完成绘制后立即打开弹窗,会触发地图残留事件。
解决方案:
javascript
setTimeout(() => {
modalOpen.value = true
}, 100) // 延迟100ms确保事件队列清空
五、数据结构设计
javascript
// 网格数据模型
const gridData = {
id: 1, // 唯一标识
code: 'WG-001', // 网格编码
name: '核心商圈网格', // 网格名称
points: [[lng, lat], ...], // 多边形顶点坐标
range: '人民广场区域', // 管辖范围描述
collector: '张三', // 负责人
color: '#5b8ff9' // 展示颜色
}
六、性能优化技巧
1. 图层管理
javascript
let polygonList = [] // 存储所有多边形
let labelList = [] // 存储所有标签
// 重新渲染前先清除旧图层
const renderPolygons = () => {
polygonList.forEach(p => mapRef.remove(p))
labelList.forEach(l => mapRef.remove(l))
// ... 重新创建
}
2. 防抖处理
javascript
let lastClickTime = 0
clickHandler = (e) => {
if (Date.now() - lastClickTime < 400) return // 400ms防抖
lastClickTime = Date.now()
// ... 处理逻辑
}
七、完整代码结构以及完整代码
├── template
│ ├── 地图容器
│ ├── 搜索/操作浮层
│ ├── 网格列表
│ └── 新增/编辑弹窗
├── script
│ ├── 数据管理(gridList, searchKeyword)
│ ├── 地图初始化(initMap)
│ ├── 绘制功能(startDraw, finishDraw)
│ ├── 渲染功能(renderPolygons)
│ └── CRUD操作(handleSave, handleEdit)
└── style
├── 布局样式(flex布局)
├── 浮层样式(绝对定位)
└── 弹窗样式(模态框)
vue
<template>
<div class="grid-planning-page">
<!-- 左侧地图区域 -->
<div class="map-area">
<!-- 搜索栏(浮层) -->
<div class="float-search">
<input
v-model="searchKeyword"
placeholder="网格编码、名称"
style="width:200px; height:32px; padding:0 11px; border:1px solid #d9d9d9; border-radius:6px; outline:none;"
@keyup.enter="handleSearch"
/>
<button class="ant-btn ant-btn-primary" @click="handleSearch">搜索</button>
</div>
<!-- 操作栏(浮层) -->
<div class="float-action" @click.stop @mousedown.stop>
<template v-if="isDrawing">
<button class="ant-btn ant-btn-primary" size="small" @mousedown.stop @click.stop="cancelDraw">取消绘制</button>
<span class="action-tip">在地图上单击添加顶点,点击「完成绘制」保存区域</span>
<button
v-if="drawingPoints.length >= 3"
class="ant-btn ant-btn-success"
size="small"
@mousedown.stop
@click.stop="finishDraw"
>完成绘制</button>
</template>
<template v-else>
<button class="ant-btn ant-btn-primary" size="small" @mousedown.stop @click.stop="startDraw">
➕ 新增网格
</button>
</template>
</div>
<!-- 地图容器 -->
<div id="gridMapContainer" class="map-container"></div>
</div>
<!-- 右侧网格列表 -->
<div class="grid-list-panel">
<div class="list-title">网格列表</div>
<div class="list-body">
<div
v-for="item in filteredGridList"
:key="item.id"
class="grid-card"
:class="{ active: activeGridId === item.id }"
@click="handleSelectGrid(item)"
>
<div class="card-row">
<div class="card-info">
<div class="info-line"><span class="lbl">网格编码</span><span class="val code">{{ item.code }}</span></div>
<div class="info-line"><span class="lbl">网格名称</span><span class="val">{{ item.name }}</span></div>
<div class="info-line"><span class="lbl">范围</span><span class="val range">{{ item.range }}</span></div>
<div class="info-line"><span class="lbl">负责人</span><span class="val">{{ item.collector }}</span></div>
</div>
<button class="ant-btn" size="small" @click.stop="handleEditGrid(item)">编辑</button>
</div>
</div>
<div v-if="filteredGridList.length === 0" class="empty-tip">暂无网格数据</div>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<div v-if="modalOpen" class="modal-mask" @click.self="handleModalCancel">
<div class="modal-container">
<div class="modal-header">
<span>{{ isEditMode ? '编辑网格' : '新增网格' }}</span>
<button class="modal-close" @click="handleModalCancel">✕</button>
</div>
<div class="modal-body">
<div class="form-row">
<label class="form-label required">网格名称</label>
<input v-model="form.name" class="form-input" placeholder="请输入网格名称" />
</div>
<div class="form-row">
<label class="form-label">网格编码</label>
<input v-model="form.code" class="form-input" placeholder="如 WG-01" />
</div>
<div class="form-row">
<label class="form-label">负责人</label>
<input v-model="form.managerName" class="form-input" placeholder="请输入负责人" />
</div>
<div class="form-row">
<label class="form-label">管辖区域</label>
<input v-model="form.area" class="form-input" placeholder="请输入管辖区域描述" />
</div>
<div class="form-row">
<label class="form-label">绘制区域</label>
<div class="polygon-tip" :class="drawingPoints.length >= 3 ? 'tip-ok' : 'tip-warn'">
<template v-if="drawingPoints.length >= 3">
✅ 已绘制 {{ drawingPoints.length }} 个顶点
</template>
<template v-else>
⚠️ 请先在地图上绘制区域(至少3个顶点)
</template>
</div>
</div>
</div>
<div class="modal-footer">
<button class="ant-btn" @click="handleModalCancel">取消</button>
<button class="ant-btn ant-btn-primary" :disabled="saving" @click="handleSave">保存</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import AMapLoader from '@amap/amap-jsapi-loader'
const COLORS = ['#5b8ff9', '#61d9aa', '#f6bd16', '#e86452', '#a371f7', '#ff9d4d']
// ---- 模拟数据(可直接替换为真实数据) ----
const mockGridList = [
{
id: 1,
code: 'WG-001',
name: '核心商圈网格',
area: '人民广场至南京东路区域',
managerName: '张三',
boundary: [[121.473, 31.23], [121.478, 31.23], [121.478, 31.235], [121.473, 31.235]]
},
{
id: 2,
code: 'WG-002',
name: '科技园区网格',
area: '张江高科园区',
managerName: '李四',
boundary: [[121.61, 31.19], [121.62, 31.19], [121.62, 31.2], [121.61, 31.2]]
},
{
id: 3,
code: 'WG-003',
name: '滨江网格',
area: '外滩至陆家嘴滨江',
managerName: '王五',
boundary: [[121.49, 31.24], [121.5, 31.235], [121.495, 31.245]]
}
]
// 将模拟数据转换为网格列表格式
const initGridList = () => {
return mockGridList.map((item, idx) => ({
id: item.id,
code: item.code || '',
name: item.name || '',
range: item.area || '',
collector: item.managerName || '',
color: COLORS[idx % COLORS.length],
points: item.boundary || []
}))
}
// ---- 网格数据 ----
const gridList = ref(initGridList())
// ---- 搜索 ----
const searchKeyword = ref('')
const filteredGridList = computed(() => {
const kw = searchKeyword.value.trim()
if (!kw) return gridList.value
return gridList.value.filter(g => g.code.includes(kw) || g.name.includes(kw))
})
const handleSearch = () => {
// 本地搜索,无需调用接口
searchKeyword.value = searchKeyword.value
}
// ---- 选中 ----
const activeGridId = ref(null)
const handleSelectGrid = (grid) => {
activeGridId.value = grid.id
if (!mapRef || !grid.points.length) return
const cx = grid.points.reduce((s, p) => s + p[0], 0) / grid.points.length
const cy = grid.points.reduce((s, p) => s + p[1], 0) / grid.points.length
mapRef.setCenter([cx, cy])
}
// ---- 弹窗表单 ----
const modalOpen = ref(false)
const isEditMode = ref(false)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({
name: '',
code: '',
managerName: '',
area: ''
})
// 生成唯一ID
const generateId = () => {
return Math.max(...gridList.value.map(g => g.id), 0) + 1
}
const handleEditGrid = (grid) => {
isEditMode.value = true
editingId.value = grid.id
form.name = grid.name
form.code = grid.code
form.managerName = grid.collector
form.area = grid.range
drawingPoints.value = grid.points.map(p => [...p])
modalOpen.value = true
}
const handleModalCancel = () => {
if (!isEditMode.value) {
drawingPoints.value = []
}
modalOpen.value = false
}
const handleSave = async () => {
if (!form.name.trim()) {
alert('请输入网格名称')
return
}
if (drawingPoints.value.length < 3) {
alert('请先在地图上绘制区域(至少3个顶点)')
return
}
saving.value = true
if (isEditMode.value) {
// 编辑网格
const index = gridList.value.findIndex(g => g.id === editingId.value)
if (index !== -1) {
gridList.value[index] = {
...gridList.value[index],
name: form.name,
code: form.code,
collector: form.managerName,
range: form.area,
points: drawingPoints.value.map(p => [...p])
}
}
alert('编辑成功')
} else {
// 新增网格
const newGrid = {
id: generateId(),
code: form.code || `WG-${String(gridList.value.length + 1).padStart(3, '0')}`,
name: form.name,
range: form.area,
collector: form.managerName,
color: COLORS[gridList.value.length % COLORS.length],
points: drawingPoints.value.map(p => [...p])
}
gridList.value.push(newGrid)
alert('新增成功')
}
drawingPoints.value = []
modalOpen.value = false
renderPolygons()
saving.value = false
}
// ---- 地图 ----
let mapRef = null
let AMapRef = null
let polygonList = []
let labelList = []
// ---- 绘制 ----
const isDrawing = ref(false)
const drawingPoints = ref([])
let tempPolyline = null
let tempDots = []
let clickHandler = null
let dblClickHandler = null
let lastClickTime = 0
const startDraw = () => {
if (!mapRef) {
alert('地图尚未加载')
return
}
isDrawing.value = true
drawingPoints.value = []
lastClickTime = 0
mapRef.setStatus({ doubleClickZoom: false })
mapRef.setDefaultCursor('crosshair')
clickHandler = (e) => {
const now = Date.now()
if (now - lastClickTime < 400) return
lastClickTime = now
const lng = e.lnglat.getLng()
const lat = e.lnglat.getLat()
drawingPoints.value.push([lng, lat])
refreshTempDraw()
}
mapRef.on('click', clickHandler)
dblClickHandler = (e) => {
e.stopPropagation && e.stopPropagation()
if (drawingPoints.value.length >= 3) {
setTimeout(() => finishDraw(), 50)
}
}
mapRef.on('dblclick', dblClickHandler)
}
const refreshTempDraw = () => {
if (!AMapRef || !mapRef) return
if (tempPolyline) mapRef.remove(tempPolyline)
tempDots.forEach(d => mapRef.remove(d))
tempDots = []
const pts = drawingPoints.value
if (pts.length < 2) {
if (pts.length === 1) {
const dot = new AMapRef.Marker({
position: pts[0],
content: '<div style="width:10px;height:10px;background:#1677ff;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,.4);margin:-5px 0 0 -5px"></div>',
offset: new AMapRef.Pixel(0, 0)
})
mapRef.add(dot)
tempDots.push(dot)
}
return
}
const pathForLine = pts.length >= 3 ? [...pts, pts[0]] : pts
tempPolyline = new AMapRef.Polyline({
path: pathForLine,
strokeColor: '#1677ff',
strokeWeight: 2,
strokeStyle: 'dashed',
strokeOpacity: 0.9
})
mapRef.add(tempPolyline)
pts.forEach((pt, i) => {
const isFirst = i === 0
const canClose = isFirst && pts.length >= 3
const content = canClose
? '<div style="width:16px;height:16px;background:#52c41a;border:3px solid #fff;border-radius:50%;box-shadow:0 0 8px rgba(82,196,26,.9);margin:-8px 0 0 -8px;cursor:pointer;"></div>'
: '<div style="width:10px;height:10px;background:#1677ff;border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,.4);margin:-5px 0 0 -5px"></div>'
const dot = new AMapRef.Marker({
position: pt,
content,
offset: new AMapRef.Pixel(0, 0)
})
mapRef.add(dot)
tempDots.push(dot)
if (canClose) {
dot.on('click', (e) => {
e.originEvent && e.originEvent.stopPropagation()
e.stopPropagation && e.stopPropagation()
lastClickTime = Date.now()
finishDraw()
})
}
})
}
const clearTempDraw = () => {
if (tempPolyline && mapRef) {
mapRef.remove(tempPolyline)
tempPolyline = null
}
tempDots.forEach(d => mapRef && mapRef.remove(d))
tempDots = []
}
const removeDrawListeners = () => {
if (mapRef) {
if (clickHandler) {
mapRef.off('click', clickHandler)
clickHandler = null
}
if (dblClickHandler) {
mapRef.off('dblclick', dblClickHandler)
dblClickHandler = null
}
}
}
const finishDraw = () => {
if (drawingPoints.value.length < 3) {
alert('至少需要3个顶点才能完成绘制')
return
}
clearTempDraw()
removeDrawListeners()
if (mapRef) {
mapRef.setStatus({ doubleClickZoom: true })
mapRef.setDefaultCursor('default')
}
isDrawing.value = false
isEditMode.value = false
editingId.value = null
form.name = ''
form.code = ''
form.managerName = ''
form.area = ''
setTimeout(() => {
modalOpen.value = true
}, 100)
}
const cancelDraw = () => {
clearTempDraw()
removeDrawListeners()
if (mapRef) {
mapRef.setStatus({ doubleClickZoom: true })
mapRef.setDefaultCursor('default')
}
drawingPoints.value = []
isDrawing.value = false
}
// ---- 渲染多边形 ----
const renderPolygons = () => {
if (!AMapRef || !mapRef) return
polygonList.forEach(p => mapRef.remove(p))
labelList.forEach(l => mapRef.remove(l))
polygonList = []
labelList = []
gridList.value.forEach(grid => {
if (!grid.points || grid.points.length < 3) return
const polygon = new AMapRef.Polygon({
path: grid.points,
fillColor: grid.color,
fillOpacity: 0.45,
strokeColor: grid.color,
strokeWeight: 2,
strokeOpacity: 1
})
mapRef.add(polygon)
polygonList.push(polygon)
polygon.on('click', () => handleSelectGrid(grid))
const cx = grid.points.reduce((s, p) => s + p[0], 0) / grid.points.length
const cy = grid.points.reduce((s, p) => s + p[1], 0) / grid.points.length
const label = new AMapRef.Marker({
position: [cx, cy],
content: `<div style="background:rgba(0,0,0,.55);color:#fff;padding:5px 10px;border-radius:4px;font-size:12px;line-height:1.7;white-space:nowrap;pointer-events:none"><div>网格编码:${grid.code}</div><div>网格名称:${grid.name}</div></div>`,
offset: new AMapRef.Pixel(-65, -32)
})
mapRef.add(label)
labelList.push(label)
})
}
// ---- 初始化地图 ----
const initMap = async () => {
try {
const AMap = await AMapLoader.load({
key: 'f15e324b6782545f2xxxx5c2b0a7', // 请替换为你自己的高德地图 Key
version: '2.0',
plugins: ['AMap.Scale']
})
AMapRef = AMap
mapRef = new AMap.Map('gridMapContainer', {
zoom: 12,
center: [121.473, 31.23], // 上海中心坐标
viewMode: '2D',
resizeEnable: true,
mapStyle: 'amap://styles/normal'
})
mapRef.addControl(new AMap.Scale())
renderPolygons()
} catch (e) {
console.error('地图加载失败:', e)
alert('地图加载失败,请检查 Key 是否正确')
}
}
onMounted(async () => {
await initMap()
})
onBeforeUnmount(() => {
cancelDraw()
if (mapRef) mapRef.destroy()
})
</script>
<style scoped>
.grid-planning-page {
display: flex;
height: 100vh;
width: 100%;
min-height: 0;
background: #fff;
overflow: hidden;
}
/* ── 地图区域 ── */
.map-area {
flex: 1;
min-width: 0;
position: relative;
overflow: hidden;
}
.map-container {
width: 100%;
height: 100%;
}
/* ── 搜索浮层 ── */
.float-search {
position: absolute;
top: 14px;
left: 14px;
z-index: 9999;
display: flex;
gap: 8px;
align-items: center;
background: #fff;
padding: 8px 10px;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
pointer-events: auto;
}
/* ── 操作浮层 ── */
.float-action {
position: absolute;
top: 68px;
left: 14px;
z-index: 9999;
display: flex;
gap: 10px;
align-items: center;
background: #fff;
padding: 7px 12px;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
pointer-events: auto;
}
.action-tip {
font-size: 13px;
color: #1677ff;
}
/* ── 按钮样式 ── */
.ant-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 15px;
font-size: 14px;
border-radius: 6px;
cursor: pointer;
border: 1px solid #d9d9d9;
background: #fff;
transition: all 0.2s;
}
.ant-btn-primary {
background: #1677ff;
border-color: #1677ff;
color: #fff;
}
.ant-btn-primary:hover {
background: #4096ff;
}
.ant-btn-success {
background: #52c41a;
border-color: #52c41a;
color: #fff;
}
.ant-btn-success:hover {
background: #73d13d;
}
/* ── 右侧网格列表 ── */
.grid-list-panel {
width: 300px;
flex-shrink: 0;
border-left: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
background: #fff;
overflow: hidden;
}
.list-title {
font-size: 15px;
font-weight: 600;
color: #1f2d3d;
padding: 16px 18px 12px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.list-body {
flex: 1;
overflow-y: auto;
}
.list-body::-webkit-scrollbar {
width: 4px;
}
.list-body::-webkit-scrollbar-thumb {
background: #e0e0e0;
border-radius: 4px;
}
.grid-card {
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background 0.15s;
}
.grid-card:hover {
background: #f5f8ff;
}
.grid-card.active {
background: #e6f4ff;
}
.card-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.card-info {
flex: 1;
min-width: 0;
}
.info-line {
display: flex;
gap: 6px;
margin-bottom: 5px;
font-size: 13px;
align-items: flex-start;
}
.info-line:last-child {
margin-bottom: 0;
}
.lbl {
color: #8c8c8c;
flex-shrink: 0;
min-width: 52px;
}
.val {
color: #262626;
word-break: break-all;
}
.val.code {
color: #1677ff;
font-weight: 600;
}
.val.range {
color: #595959;
font-size: 12px;
line-height: 1.5;
}
.empty-tip {
text-align: center;
color: #bfbfbf;
font-size: 13px;
padding: 40px 0;
}
/* ── 弹窗样式 ── */
.modal-mask {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.45);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-container {
width: 620px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
font-size: 16px;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: #8c8c8c;
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 10px 16px;
text-align: right;
border-top: 1px solid #f0f0f0;
gap: 8px;
display: flex;
justify-content: flex-end;
}
.form-row {
display: flex;
margin-bottom: 16px;
align-items: center;
}
.form-label {
width: 80px;
flex-shrink: 0;
font-size: 14px;
color: #262626;
}
.form-label.required::before {
content: '*';
color: #ff4d4f;
margin-right: 4px;
}
.form-input {
flex: 1;
padding: 4px 11px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
outline: none;
}
.form-input:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.2);
}
/* ── 弹窗内绘制提示 ── */
.polygon-tip {
display: flex;
align-items: center;
font-size: 13px;
padding: 7px 10px;
border-radius: 4px;
border: 1px solid;
flex: 1;
}
.tip-ok {
color: #52c41a;
background: #f6ffed;
border-color: #b7eb8f;
}
.tip-warn {
color: #d46b08;
background: #fff7e6;
border-color: #ffd591;
}
</style>
八、运行与部署
环境要求
- Node.js 16+
- Vue 3 项目
快速开始
-
申请高德地图 Key
- 访问 高德开放平台
- 创建应用获取 Web 端 Key
-
安装依赖
bash
npm install @amap/amap-jsapi-loader
- 配置环境变量
bash
VITE_AMAP_KEY=你的高德地图Key
- 启动项目
bash
npm run dev
九、踩坑记录
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 地图显示空白 | 容器未设置高度 | 给 .map-container 设置 height: 100% |
| 多边形不显示 | 在地图初始化前调用 | 使用 await initMap() 确保完成 |
| 绘制时地图乱跳 | 双击缩放未禁用 | setStatus({ doubleClickZoom: false }) |
| 内存泄漏 | 地图实例未销毁 | onBeforeUnmount 中调用 mapRef.destroy() |
十、总结
通过本文,我们实现了一个完整的网格规划系统,涵盖了:
- ✅ 高德地图的集成与配置
- ✅ 交互式多边形绘制(顶点添加、实时预览、双击完成)
- ✅ 网格数据的可视化渲染
- ✅ 列表与地图的双向联动
- ✅ 完整的 CRUD 操作
这套代码可以直接应用于智慧城市、社区管理、物流分区等场景。核心绘制逻辑也可扩展用于电子围栏、兴趣点区域标注等功能。
十一、源码获取
完整代码已整理,复制即可运行。只需要:
- 替换高德地图 Key
- 安装依赖
- 启动项目
如果觉得有用,欢迎点赞收藏!有问题请在评论区留言讨论。