基于 Vue 3 + 高德地图的网格规划系统实战(有源码)

基于 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 项目

快速开始

  1. 申请高德地图 Key

  2. 安装依赖

bash 复制代码
npm install @amap/amap-jsapi-loader
  1. 配置环境变量
bash 复制代码
VITE_AMAP_KEY=你的高德地图Key
  1. 启动项目
bash 复制代码
npm run dev

九、踩坑记录

问题 原因 解决方案
地图显示空白 容器未设置高度 .map-container 设置 height: 100%
多边形不显示 在地图初始化前调用 使用 await initMap() 确保完成
绘制时地图乱跳 双击缩放未禁用 setStatus({ doubleClickZoom: false })
内存泄漏 地图实例未销毁 onBeforeUnmount 中调用 mapRef.destroy()

十、总结

通过本文,我们实现了一个完整的网格规划系统,涵盖了:

  • ✅ 高德地图的集成与配置
  • ✅ 交互式多边形绘制(顶点添加、实时预览、双击完成)
  • ✅ 网格数据的可视化渲染
  • ✅ 列表与地图的双向联动
  • ✅ 完整的 CRUD 操作

这套代码可以直接应用于智慧城市、社区管理、物流分区等场景。核心绘制逻辑也可扩展用于电子围栏、兴趣点区域标注等功能。

十一、源码获取

完整代码已整理,复制即可运行。只需要:

  1. 替换高德地图 Key
  2. 安装依赖
  3. 启动项目

如果觉得有用,欢迎点赞收藏!有问题请在评论区留言讨论。

相关推荐
逸A1 小时前
某里v2反混淆 codec 化路上踩到的两个隐蔽坑:被清零的 salt 与 opaque loop bound
javascript·人工智能·目标跟踪
丷丩1 小时前
MapLibre GL JS第11课:获取鼠标指针坐标
前端·javascript·gis·地图·mapbox·maplibre gl js
代码AI弗森2 小时前
前端周刊第 467 期[特殊字符] 本期精选目录
前端
随便的名字2 小时前
前端路由的底层逻辑:URL 中 # 和 ? 的区别与关系详解
前端
kongba0072 小时前
ttyd Web终端安装指南(OpenCloudOS 9)
linux·前端
zhoumeina992 小时前
前端串行合成流程 + 每张图上传接口
前端·状态模式
风骏时光牛马2 小时前
Swift 基于MVVM架构实现完整列表数据展示与交互功能实战案例
前端
就叫_这个吧2 小时前
JavaScript基础数据类型、运算符、数组、函数的定义及DOM方式应用
开发语言·前端·javascript
作业逆流成河2 小时前
别再一次性重构枚举了:如何把一个真实后台项目的状态字典,渐进式迁移到enum-plus?
前端·javascript·开源