实现危险区设置

pull/20/head
dmz 6 months ago
parent a4c0618725
commit 1dff5ea036

@ -0,0 +1,131 @@
# 无人机指挥中心 - 集成地图功能说明
## 概述
原本的系统有三个分离的地图界面:
- 地图视图 (MapView.vue)
- 威胁区设置 (ThreatZoneView.vue)
- 路径规划 (PathPlanningView.vue)
现在已经集成为一个统一的地图界面 (UnifiedMapView.vue),包含所有三个功能的完整实现。
## 功能特性
### 1. 地图视图功能
- ✅ 无人机标记显示和隐藏
- ✅ 无人机信息窗体(点击显示详情)
- ✅ 目标点添加(点击地图添加)
- ✅ 目标点清除功能
- ✅ 地图样式切换(标准/卫星/暗色)
- ✅ 2D/3D视图切换
- ✅ 图层叠加控制
### 2. 威胁区设置功能
- ✅ 威胁区类型选择(雷达/导弹/空中/地面/气象)
- ✅ 绘制工具(圆形/多边形/矩形区域)
- ✅ 威胁等级设置(低/中/高/严重)
- ✅ 威胁区参数配置(半径、描述、时间范围)
- ✅ 威胁区列表管理
- ✅ 威胁区编辑/删除功能
- ✅ 图层显示控制
- ✅ 威胁区高亮选择
### 3. 路径规划功能
- ✅ 无人机选择
- ✅ 路径点添加(点击地图)
- ✅ 路径点管理(删除、重新排序)
- ✅ 规划算法选择A*/RRT/直线)
- ✅ 飞行参数设置(高度、速度)
- ✅ 路径信息计算(距离、时间、点数)
- ✅ 路径执行功能
- ✅ 地图视图控制(适合路径、定位起终点)
## 技术实现
### 核心组件
1. **UnifiedMapView.vue** - 主容器组件
- 集成三个功能面板
- 统一的地图实例管理
- 事件分发和状态管理
2. **SharedMap.vue** - 共享地图组件
- 高德地图API封装
- 支持多种插件MouseTool, PolyEditor等
- 地图样式和控件管理
3. **面板组件**
- BasicMapPanel.vue - 基础地图控制
- ThreatZonePanel.vue - 威胁区设置面板
- PathPlanningPanel.vue - 路径规划面板
### 关键功能
#### 地图操作
```javascript
// 目标点添加
const addTargetPoint = (lng, lat) => {
// 创建标记,添加到地图
}
// 威胁区绘制
const setDrawMode = (mode) => {
// 启用鼠标绘制工具
mouseTool.value.circle(options) // 圆形
mouseTool.value.polygon(options) // 多边形
mouseTool.value.rectangle(options) // 矩形
}
// 路径规划
const addPathPoint = (lng, lat) => {
// 添加路径点并绘制连接线
}
```
#### 状态管理
- 使用 Vuex store 管理无人机数据
- 响应式状态更新
- 面板间状态同步
## 使用方法
### 1. 切换功能模式
通过顶部选项卡切换:
- 地图视图 - 基础地图操作和目标点管理
- 威胁区设置 - 威胁区域绘制和管理
- 路径规划 - 无人机路径规划和执行
### 2. 地图交互
- **添加模式**: 启用后点击地图添加目标点或路径点
- **绘制模式**: 选择绘制工具后在地图上绘制威胁区
- **选择模式**: 点击已有元素进行选择和编辑
### 3. 数据持久化
- 威胁区数据可导出/导入
- 路径规划结果可保存
- 无人机状态实时同步
## 浏览器访问
启动前端服务:
```bash
cd web-command-center/frontend
npm run serve
```
访问 `http://localhost:8080/map` 查看集成地图界面。
## 注意事项
1. **API密钥**: 确保高德地图API密钥有效
2. **后端服务**: 无人机数据需要后端API支持
3. **浏览器兼容**: 推荐使用Chrome/Firefox最新版本
4. **性能优化**: 大量标记时建议启用聚合显示
## 后续优化
- [ ] 实时数据推送
- [ ] 更多地图图层支持
- [ ] 威胁区避让算法
- [ ] 路径优化算法
- [ ] 多无人机协同规划

@ -1,58 +1,6 @@
const express = require('express')
const router = express.Router()
// 存储威胁区数据(实际项目中应使用数据库)
let threatZones = [
{
id: 1,
type: 'radar',
level: 'high',
description: '敌方雷达基站',
geometry: {
type: 'circle',
center: [116.397428, 39.91],
radius: 2000
},
timeRange: ['2024-01-01T00:00:00', '2024-12-31T23:59:59'],
status: 'active',
createdAt: new Date('2024-01-01T10:00:00'),
updatedAt: new Date('2024-01-01T10:00:00')
},
{
id: 2,
type: 'missile',
level: 'critical',
description: '导弹发射阵地',
geometry: {
type: 'circle',
center: [116.42, 39.89],
radius: 3000
},
timeRange: ['2024-01-01T00:00:00', '2024-12-31T23:59:59'],
status: 'active',
createdAt: new Date('2024-01-01T11:00:00'),
updatedAt: new Date('2024-01-01T11:00:00')
},
{
id: 3,
type: 'aircraft',
level: 'medium',
description: '空中巡逻区域',
geometry: {
type: 'polygon',
path: [
[116.38, 39.92],
[116.40, 39.92],
[116.40, 39.90],
[116.38, 39.90]
]
},
timeRange: ['2024-01-01T06:00:00', '2024-01-01T18:00:00'],
status: 'active',
createdAt: new Date('2024-01-01T09:00:00'),
updatedAt: new Date('2024-01-01T09:00:00')
}
]
const db = require('../config/database')
// 威胁类型配置
const threatTypes = {
@ -117,45 +65,72 @@ const threatLevels = {
}
// 获取所有威胁区
router.get('/', (req, res) => {
router.get('/', async (req, res) => {
try {
const { type, level, status, active } = req.query
let filteredZones = [...threatZones]
let sql = `
SELECT
id, type, level, description, geometry_type, geometry_data,
time_start, time_end, status, created_by, created_at, updated_at
FROM threat_zones
WHERE 1=1
`
const params = []
// 按类型筛选
if (type) {
filteredZones = filteredZones.filter(zone => zone.type === type)
sql += ' AND type = ?'
params.push(type)
}
// 按威胁等级筛选
if (level) {
filteredZones = filteredZones.filter(zone => zone.level === level)
sql += ' AND level = ?'
params.push(level)
}
// 按状态筛选
if (status) {
filteredZones = filteredZones.filter(zone => zone.status === status)
sql += ' AND status = ?'
params.push(status)
}
// 按时间范围筛选(当前时间是否在威胁区有效期内)
if (active === 'true') {
const now = new Date()
filteredZones = filteredZones.filter(zone => {
if (!zone.timeRange || zone.timeRange.length < 2) return true
const startTime = new Date(zone.timeRange[0])
const endTime = new Date(zone.timeRange[1])
return now >= startTime && now <= endTime
})
sql += ' AND (time_start IS NULL OR time_start <= NOW()) AND (time_end IS NULL OR time_end >= NOW())'
}
sql += ' ORDER BY created_at DESC'
const [rows] = await db.execute(sql, params)
// 转换数据格式,兼容前端
const threatZones = rows.map(row => ({
id: row.id,
type: row.type,
level: row.level,
description: row.description,
geometry: {
type: row.geometry_type,
...row.geometry_data
},
timeRange: [row.time_start, row.time_end],
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
typeConfig: threatTypes[row.type],
levelConfig: threatLevels[row.level]
}))
res.json({
success: true,
data: filteredZones,
total: filteredZones.length,
data: threatZones,
total: threatZones.length,
message: '获取威胁区列表成功'
})
} catch (error) {
console.error('获取威胁区列表失败:', error)
res.status(500).json({
success: false,
message: '获取威胁区列表失败',
@ -165,31 +140,47 @@ router.get('/', (req, res) => {
})
// 获取单个威胁区详情
router.get('/:id', (req, res) => {
router.get('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id)
const zone = threatZones.find(z => z.id === id)
if (!zone) {
const [rows] = await db.execute(
'SELECT * FROM threat_zones WHERE id = ?',
[id]
)
if (rows.length === 0) {
return res.status(404).json({
success: false,
message: '威胁区不存在'
})
}
// 添加威胁区配置信息
const zoneWithConfig = {
...zone,
typeConfig: threatTypes[zone.type],
levelConfig: threatLevels[zone.level]
const row = rows[0]
const zone = {
id: row.id,
type: row.type,
level: row.level,
description: row.description,
geometry: {
type: row.geometry_type,
...row.geometry_data
},
timeRange: [row.time_start, row.time_end],
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
typeConfig: threatTypes[row.type],
levelConfig: threatLevels[row.level]
}
res.json({
success: true,
data: zoneWithConfig,
data: zone,
message: '获取威胁区详情成功'
})
} catch (error) {
console.error('获取威胁区详情失败:', error)
res.status(500).json({
success: false,
message: '获取威胁区详情失败',
@ -198,16 +189,16 @@ router.get('/:id', (req, res) => {
}
})
// 创建威胁区
router.post('/', (req, res) => {
// 创建威胁区
router.post('/', async (req, res) => {
try {
const { type, level, description, geometry, timeRange } = req.body
const { type, level, description, geometry, timeRange, status = 'active' } = req.body
// 验证必字段
// 验证必字段
if (!type || !level || !geometry) {
return res.status(400).json({
success: false,
message: '请提供威胁类型、威胁等级和几何信息'
message: '威胁类型、威胁等级和几何形状为必需字段'
})
}
@ -215,7 +206,7 @@ router.post('/', (req, res) => {
if (!threatTypes[type]) {
return res.status(400).json({
success: false,
message: '不支持的威胁类型'
message: '无效的威胁类型'
})
}
@ -223,38 +214,71 @@ router.post('/', (req, res) => {
if (!threatLevels[level]) {
return res.status(400).json({
success: false,
message: '不支持的威胁等级'
message: '无效的威胁等级'
})
}
// 验证几何信息
if (!validateGeometry(geometry)) {
// 验证几何形状
const validationResult = validateGeometry(geometry)
if (!validationResult.valid) {
return res.status(400).json({
success: false,
message: '无效的几何信息'
message: `无效的几何形状: ${validationResult.error}`
})
}
// 准备几何数据
const geometryData = { ...geometry }
delete geometryData.type
// 处理时间范围
const timeStart = timeRange && timeRange[0] ? new Date(timeRange[0]) : null
const timeEnd = timeRange && timeRange[1] ? new Date(timeRange[1]) : null
const [result] = await db.execute(
`INSERT INTO threat_zones
(type, level, description, geometry_type, geometry_data, time_start, time_end, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
type,
level,
description || '',
geometry.type,
JSON.stringify(geometryData),
timeStart,
timeEnd,
status
]
)
// 获取创建的威胁区
const [newRows] = await db.execute(
'SELECT * FROM threat_zones WHERE id = ?',
[result.insertId]
)
const newZone = {
id: Math.max(...threatZones.map(z => z.id), 0) + 1,
type,
level,
description: description || '',
geometry,
timeRange: timeRange || null,
status: 'active',
createdAt: new Date(),
updatedAt: new Date()
id: newRows[0].id,
type: newRows[0].type,
level: newRows[0].level,
description: newRows[0].description,
geometry: {
type: newRows[0].geometry_type,
...newRows[0].geometry_data
},
timeRange: [newRows[0].time_start, newRows[0].time_end],
status: newRows[0].status,
createdAt: newRows[0].created_at,
updatedAt: newRows[0].updated_at
}
threatZones.push(newZone)
res.status(201).json({
success: true,
data: newZone,
message: '威胁区创建成功'
})
} catch (error) {
console.error('创建威胁区失败:', error)
res.status(500).json({
success: false,
message: '创建威胁区失败',
@ -264,59 +288,132 @@ router.post('/', (req, res) => {
})
// 更新威胁区
router.put('/:id', (req, res) => {
router.put('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id)
const zoneIndex = threatZones.findIndex(z => z.id === id)
const { type, level, description, geometry, timeRange, status } = req.body
// 检查威胁区是否存在
const [existingRows] = await db.execute(
'SELECT id FROM threat_zones WHERE id = ?',
[id]
)
if (zoneIndex === -1) {
if (existingRows.length === 0) {
return res.status(404).json({
success: false,
message: '威胁区不存在'
})
}
const { type, level, description, geometry, timeRange, status } = req.body
const zone = threatZones[zoneIndex]
// 验证威胁类型
if (type && !threatTypes[type]) {
return res.status(400).json({
success: false,
message: '无效的威胁类型'
})
}
// 验证威胁等级
if (level && !threatLevels[level]) {
return res.status(400).json({
success: false,
message: '无效的威胁等级'
})
}
// 更新威胁类型
if (type && threatTypes[type]) {
zone.type = type
// 验证几何形状
if (geometry) {
const validationResult = validateGeometry(geometry)
if (!validationResult.valid) {
return res.status(400).json({
success: false,
message: `无效的几何形状: ${validationResult.error}`
})
}
}
// 更新威胁等级
if (level && threatLevels[level]) {
zone.level = level
// 构建更新SQL
const updateFields = []
const params = []
if (type) {
updateFields.push('type = ?')
params.push(type)
}
if (level) {
updateFields.push('level = ?')
params.push(level)
}
// 更新描述
if (description !== undefined) {
zone.description = description
updateFields.push('description = ?')
params.push(description)
}
if (geometry) {
updateFields.push('geometry_type = ?, geometry_data = ?')
const geometryData = { ...geometry }
delete geometryData.type
params.push(geometry.type, JSON.stringify(geometryData))
}
// 更新几何信息
if (geometry && validateGeometry(geometry)) {
zone.geometry = geometry
if (timeRange) {
updateFields.push('time_start = ?, time_end = ?')
params.push(
timeRange[0] ? new Date(timeRange[0]) : null,
timeRange[1] ? new Date(timeRange[1]) : null
)
}
// 更新时间范围
if (timeRange !== undefined) {
zone.timeRange = timeRange
if (status) {
updateFields.push('status = ?')
params.push(status)
}
// 更新状态
if (status && ['active', 'inactive', 'expired'].includes(status)) {
zone.status = status
if (updateFields.length === 0) {
return res.status(400).json({
success: false,
message: '没有提供要更新的字段'
})
}
zone.updatedAt = new Date()
params.push(id)
await db.execute(
`UPDATE threat_zones SET ${updateFields.join(', ')} WHERE id = ?`,
params
)
// 获取更新后的威胁区
const [updatedRows] = await db.execute(
'SELECT * FROM threat_zones WHERE id = ?',
[id]
)
const updatedZone = {
id: updatedRows[0].id,
type: updatedRows[0].type,
level: updatedRows[0].level,
description: updatedRows[0].description,
geometry: {
type: updatedRows[0].geometry_type,
...updatedRows[0].geometry_data
},
timeRange: [updatedRows[0].time_start, updatedRows[0].time_end],
status: updatedRows[0].status,
createdAt: updatedRows[0].created_at,
updatedAt: updatedRows[0].updated_at
}
res.json({
success: true,
data: zone,
data: updatedZone,
message: '威胁区更新成功'
})
} catch (error) {
console.error('更新威胁区失败:', error)
res.status(500).json({
success: false,
message: '更新威胁区失败',
@ -326,25 +423,31 @@ router.put('/:id', (req, res) => {
})
// 删除威胁区
router.delete('/:id', (req, res) => {
router.delete('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id)
const zoneIndex = threatZones.findIndex(z => z.id === id)
if (zoneIndex === -1) {
// 检查威胁区是否存在
const [existingRows] = await db.execute(
'SELECT id FROM threat_zones WHERE id = ?',
[id]
)
if (existingRows.length === 0) {
return res.status(404).json({
success: false,
message: '威胁区不存在'
})
}
threatZones.splice(zoneIndex, 1)
await db.execute('DELETE FROM threat_zones WHERE id = ?', [id])
res.json({
success: true,
message: '威胁区删除成功'
})
} catch (error) {
console.error('删除威胁区失败:', error)
res.status(500).json({
success: false,
message: '删除威胁区失败',
@ -354,310 +457,90 @@ router.delete('/:id', (req, res) => {
})
// 批量删除威胁区
router.delete('/batch/:ids', (req, res) => {
router.delete('/', async (req, res) => {
try {
const ids = req.params.ids.split(',').map(id => parseInt(id))
const deletedZones = []
const notFoundIds = []
ids.forEach(id => {
const zoneIndex = threatZones.findIndex(z => z.id === id)
if (zoneIndex !== -1) {
deletedZones.push(threatZones.splice(zoneIndex, 1)[0])
} else {
notFoundIds.push(id)
}
})
let message = `成功删除${deletedZones.length}个威胁区`
if (notFoundIds.length > 0) {
message += `${notFoundIds.length}个威胁区不存在`
}
const { ids } = req.body
res.json({
success: true,
data: {
deleted: deletedZones.length,
notFound: notFoundIds.length,
notFoundIds
},
message
})
} catch (error) {
res.status(500).json({
success: false,
message: '批量删除威胁区失败',
error: error.message
})
}
})
// 获取威胁类型列表
router.get('/types/list', (req, res) => {
try {
res.json({
success: true,
data: threatTypes,
message: '获取威胁类型列表成功'
})
} catch (error) {
res.status(500).json({
success: false,
message: '获取威胁类型列表失败',
error: error.message
})
}
})
// 获取威胁等级列表
router.get('/levels/list', (req, res) => {
try {
res.json({
success: true,
data: threatLevels,
message: '获取威胁等级列表成功'
})
} catch (error) {
res.status(500).json({
success: false,
message: '获取威胁等级列表失败',
error: error.message
})
}
})
// 检查坐标是否在威胁区内
router.post('/check-point', (req, res) => {
try {
const { lng, lat, types, levels } = req.body
if (typeof lng !== 'number' || typeof lat !== 'number') {
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({
success: false,
message: '请提供有效的经纬度坐标'
message: '请提供要删除的威胁区ID列表'
})
}
let activeZones = threatZones.filter(zone => zone.status === 'active')
// 按类型筛选
if (types && Array.isArray(types)) {
activeZones = activeZones.filter(zone => types.includes(zone.type))
}
// 按等级筛选
if (levels && Array.isArray(levels)) {
activeZones = activeZones.filter(zone => levels.includes(zone.level))
}
const threatsFound = []
activeZones.forEach(zone => {
if (isPointInThreatZone(lng, lat, zone)) {
threatsFound.push({
id: zone.id,
type: zone.type,
level: zone.level,
description: zone.description,
typeConfig: threatTypes[zone.type],
levelConfig: threatLevels[zone.level]
})
}
})
// 按威胁等级优先级排序
threatsFound.sort((a, b) => b.levelConfig.priority - a.levelConfig.priority)
const placeholders = ids.map(() => '?').join(',')
const [result] = await db.execute(
`DELETE FROM threat_zones WHERE id IN (${placeholders})`,
ids
)
res.json({
success: true,
data: {
point: { lng, lat },
inThreatZone: threatsFound.length > 0,
threatsCount: threatsFound.length,
threats: threatsFound,
highestThreat: threatsFound[0] || null
deletedCount: result.affectedRows
},
message: threatsFound.length > 0 ? '坐标位于威胁区内' : '坐标安全'
})
} catch (error) {
res.status(500).json({
success: false,
message: '检查威胁区失败',
error: error.message
})
}
})
// 获取威胁区统计信息
router.get('/stats/summary', (req, res) => {
try {
const stats = {
total: threatZones.length,
active: threatZones.filter(z => z.status === 'active').length,
inactive: threatZones.filter(z => z.status === 'inactive').length,
expired: threatZones.filter(z => z.status === 'expired').length,
byType: {},
byLevel: {},
coverage: 0 // 威胁区覆盖面积(简化计算)
}
// 按类型统计
Object.keys(threatTypes).forEach(type => {
stats.byType[type] = threatZones.filter(z => z.type === type).length
})
// 按等级统计
Object.keys(threatLevels).forEach(level => {
stats.byLevel[level] = threatZones.filter(z => z.level === level).length
})
// 计算覆盖面积(简化:仅计算圆形威胁区)
stats.coverage = threatZones
.filter(z => z.geometry.type === 'circle' && z.status === 'active')
.reduce((total, zone) => {
const radius = zone.geometry.radius || 0
return total + Math.PI * radius * radius
}, 0)
res.json({
success: true,
data: stats,
message: '获取威胁区统计成功'
message: `成功删除${result.affectedRows}个威胁区`
})
} catch (error) {
console.error('批量删除威胁区失败:', error)
res.status(500).json({
success: false,
message: '获取威胁区统计失败',
message: '批量删除威胁区失败',
error: error.message
})
}
})
// 导出威胁区数据
router.get('/export/data', (req, res) => {
try {
const { format = 'json' } = req.query
if (format === 'json') {
res.json({
success: true,
data: {
threatZones,
threatTypes,
threatLevels,
exportTime: new Date(),
version: '1.0'
},
message: '威胁区数据导出成功'
})
} else {
res.status(400).json({
success: false,
message: '不支持的导出格式'
})
}
} catch (error) {
res.status(500).json({
success: false,
message: '导出威胁区数据失败',
error: error.message
})
}
// 获取威胁区配置信息
router.get('/config/types', (req, res) => {
res.json({
success: true,
data: {
types: threatTypes,
levels: threatLevels
},
message: '获取威胁区配置成功'
})
})
// 验证几何信息
// 验证几何形状
function validateGeometry(geometry) {
if (!geometry || !geometry.type) return false
switch (geometry.type) {
case 'circle':
return geometry.center &&
Array.isArray(geometry.center) &&
geometry.center.length === 2 &&
typeof geometry.radius === 'number' &&
geometry.radius > 0
case 'polygon':
return geometry.path &&
Array.isArray(geometry.path) &&
geometry.path.length >= 3 &&
geometry.path.every(point =>
Array.isArray(point) && point.length === 2
)
case 'rectangle':
return geometry.bounds &&
Array.isArray(geometry.bounds) &&
geometry.bounds.length === 4
default:
return false
if (!geometry || !geometry.type) {
return { valid: false, error: '缺少几何形状类型' }
}
}
// 检查点是否在威胁区内
function isPointInThreatZone(lng, lat, zone) {
const { geometry } = zone
switch (geometry.type) {
case 'circle':
return isPointInCircle(lng, lat, geometry.center, geometry.radius)
if (!geometry.center || !Array.isArray(geometry.center) || geometry.center.length !== 2) {
return { valid: false, error: '圆形需要有效的中心点坐标' }
}
if (!geometry.radius || geometry.radius <= 0) {
return { valid: false, error: '圆形需要有效的半径' }
}
break
case 'polygon':
return isPointInPolygon(lng, lat, geometry.path)
if (!geometry.path || !Array.isArray(geometry.path) || geometry.path.length < 3) {
return { valid: false, error: '多边形需要至少3个点' }
}
for (const point of geometry.path) {
if (!Array.isArray(point) || point.length !== 2) {
return { valid: false, error: '多边形的点必须是[经度, 纬度]格式' }
}
}
break
case 'rectangle':
return isPointInRectangle(lng, lat, geometry.bounds)
if (!geometry.bounds || !Array.isArray(geometry.bounds) || geometry.bounds.length !== 4) {
return { valid: false, error: '矩形需要有效的边界[南纬, 西经, 北纬, 东经]' }
}
break
default:
return false
return { valid: false, error: '不支持的几何形状类型' }
}
}
// 检查点是否在圆形内
function isPointInCircle(lng, lat, center, radius) {
const distance = getDistance(lat, lng, center[1], center[0])
return distance <= radius
}
// 检查点是否在多边形内(射线法)
function isPointInPolygon(lng, lat, path) {
let inside = false
for (let i = 0, j = path.length - 1; i < path.length; j = i++) {
const xi = path[i][0], yi = path[i][1]
const xj = path[j][0], yj = path[j][1]
if (((yi > lat) !== (yj > lat)) &&
(lng < (xj - xi) * (lat - yi) / (yj - yi) + xi)) {
inside = !inside
}
}
return inside
}
// 检查点是否在矩形内
function isPointInRectangle(lng, lat, bounds) {
const [minLng, minLat, maxLng, maxLat] = bounds
return lng >= minLng && lng <= maxLng && lat >= minLat && lat <= maxLat
}
// 计算两点间距离Haversine公式
function getDistance(lat1, lng1, lat2, lng2) {
const R = 6371e3 // 地球半径(米)
const φ1 = lat1 * Math.PI/180
const φ2 = lat2 * Math.PI/180
const Δφ = (lat2-lat1) * Math.PI/180
const Δλ = (lng2-lng1) * Math.PI/180
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
return R * c // 距离(米)
return { valid: true }
}
module.exports = router

@ -1,5 +1,5 @@
-- 创建数据库
CREATE DATABASE IF NOT EXISTS command_center;
CREATE DATABASE IF NOT EXISTS command_center CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE command_center;
-- 创建用户表
@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS users (
role ENUM('admin', 'operator') NOT NULL DEFAULT 'operator',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 创建无人机表
CREATE TABLE IF NOT EXISTS drones (
@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS drones (
battery INT DEFAULT 100,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 创建路径规划记录表
CREATE TABLE IF NOT EXISTS path_plans (
@ -38,7 +38,30 @@ CREATE TABLE IF NOT EXISTS path_plans (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (drone_id) REFERENCES drones(id)
);
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 删除现有威胁区表(如果存在)以确保结构正确
DROP TABLE IF EXISTS threat_zones;
-- 创建威胁区表
CREATE TABLE threat_zones (
id INT PRIMARY KEY AUTO_INCREMENT,
type ENUM('radar', 'missile', 'aircraft', 'ground', 'weather') NOT NULL,
level ENUM('low', 'medium', 'high', 'critical') NOT NULL,
description TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
geometry_type ENUM('circle', 'polygon', 'rectangle') NOT NULL,
geometry_data JSON NOT NULL COMMENT '存储几何形状数据',
time_start TIMESTAMP NULL COMMENT '威胁区开始时间',
time_end TIMESTAMP NULL COMMENT '威胁区结束时间',
status ENUM('active', 'inactive') NOT NULL DEFAULT 'active',
created_by INT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id),
INDEX idx_type (type),
INDEX idx_level (level),
INDEX idx_status (status)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 创建操作日志表
CREATE TABLE IF NOT EXISTS operation_logs (
@ -50,9 +73,27 @@ CREATE TABLE IF NOT EXISTS operation_logs (
details JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 插入默认管理员用户
INSERT INTO users (username, password, role)
VALUES ('admin', '$2a$10$X7UrH5QxX5QxX5QxX5QxX.5QxX5QxX5QxX5QxX5QxX5QxX5QxX5Qx', 'admin')
ON DUPLICATE KEY UPDATE username = username;
ON DUPLICATE KEY UPDATE username = username;
-- 插入示例威胁区数据
INSERT INTO threat_zones (type, level, description, geometry_type, geometry_data, time_start, time_end, status)
VALUES
('radar', 'high', 'Enemy radar station', 'circle',
JSON_OBJECT('center', JSON_ARRAY(116.397428, 39.91), 'radius', 2000),
'2024-01-01 00:00:00', '2024-12-31 23:59:59', 'active'),
('missile', 'critical', 'Missile launch site', 'circle',
JSON_OBJECT('center', JSON_ARRAY(116.42, 39.89), 'radius', 3000),
'2024-01-01 00:00:00', '2024-12-31 23:59:59', 'active'),
('aircraft', 'medium', 'Air patrol zone', 'polygon',
JSON_OBJECT('path', JSON_ARRAY(
JSON_ARRAY(116.38, 39.92),
JSON_ARRAY(116.40, 39.92),
JSON_ARRAY(116.40, 39.90),
JSON_ARRAY(116.38, 39.90)
)),
'2024-01-01 06:00:00', '2024-01-01 18:00:00', 'active');

@ -100,12 +100,8 @@ export default {
version: '2.0',
plugins: [
'AMap.Scale',
<<<<<<< HEAD
'AMap.ToolBar',
'AMap.ControlBar',
=======
'AMap.ToolBar',
>>>>>>> 999104f0d0174d3a3870682fb7163d95bc623970
'AMap.InfoWindow',
'AMap.Marker',
'AMap.Polyline',
@ -119,14 +115,11 @@ export default {
'AMap.TileLayer.Satellite',
'AMap.TileLayer.Traffic',
'AMap.TileLayer.RoadNet',
<<<<<<< HEAD
'AMap.Buildings',
=======
'AMap.BuildingLayer',
>>>>>>> 999104f0d0174d3a3870682fb7163d95bc623970
'AMap.DistrictLayer',
'AMap.Weather',
'AMap.Driving'
'AMap.Driving',
'AMap.PolyEditor'
]
})
@ -136,13 +129,8 @@ export default {
mapInstance.value = new AMap.Map('sharedMap', {
zoom: 11,
center: [116.397428, 39.90923],
<<<<<<< HEAD
viewMode: viewMode.value,
pitch: viewMode.value === '3D' ? 45 : 0,
=======
viewMode: '3D',
pitch: 0,
>>>>>>> 999104f0d0174d3a3870682fb7163d95bc623970
rotation: 0,
mapStyle: 'amap://styles/normal',
features: ['bg', 'road', 'building', 'point'],
@ -172,7 +160,6 @@ export default {
right: '10px'
}
})
<<<<<<< HEAD
// 3D3D
const controlBar = new AMap.ControlBar({
@ -187,11 +174,6 @@ export default {
mapInstance.value.addControl(toolbar)
mapInstance.value.addControl(scale)
mapInstance.value.addControl(controlBar)
=======
mapInstance.value.addControl(toolbar)
mapInstance.value.addControl(scale)
>>>>>>> 999104f0d0174d3a3870682fb7163d95bc623970
//
mapInstance.value.on('click', (e) => {
@ -217,11 +199,7 @@ export default {
});
//
<<<<<<< HEAD
layerInstances.value.buildings = new window.AMap.Buildings({
=======
layerInstances.value.buildings = new window.AMap.BuildingLayer({
>>>>>>> 999104f0d0174d3a3870682fb7163d95bc623970
zIndex: 11,
heightFactor: viewMode.value === '3D' ? 1 : 0.6
});
@ -356,7 +334,6 @@ export default {
const changeMapStyle = (style) => {
if (!mapInstance.value) return
<<<<<<< HEAD
try {
currentMapStyle.value = style
@ -407,60 +384,11 @@ export default {
}
}
=======
switch (style) {
case 'satellite':
// 使
if (!layerInstances.value.satellite) {
layerInstances.value.satellite = new window.AMap.TileLayer.Satellite()
}
mapInstance.value.setLayers([
layerInstances.value.satellite,
...getActiveOverlayLayers()
])
break
case 'dark':
// 使
mapInstance.value.setLayers([new window.AMap.TileLayer()])
mapInstance.value.setMapStyle('amap://styles/dark')
updateOverlayLayers() //
break
default:
// 使
mapInstance.value.setLayers([new window.AMap.TileLayer()])
mapInstance.value.setMapStyle('amap://styles/normal')
updateOverlayLayers() //
}
}
//
const getActiveOverlayLayers = () => {
const layers = []
if (!window.AMap) return layers
if (overlayLayers.value.includes('buildings') && layerInstances.value.buildings) {
layers.push(layerInstances.value.buildings)
}
if (overlayLayers.value.includes('traffic') && layerInstances.value.traffic) {
layers.push(layerInstances.value.traffic)
}
if (overlayLayers.value.includes('terrain') && layerInstances.value.terrain) {
layers.push(layerInstances.value.terrain)
}
return layers
}
>>>>>>> 999104f0d0174d3a3870682fb7163d95bc623970
//
const changeViewMode = (mode) => {
if (!mapInstance.value) return
try {
<<<<<<< HEAD
// - 使API
if (mode === '3D') {
// 3D
@ -472,21 +400,6 @@ export default {
mapInstance.value.setViewMode('2D');
mapInstance.value.setPitch(0); //
console.log('已切换到2D视图模式');
=======
//
mapInstance.value.setViewMode(mode)
//
if (mode === '3D') {
// 3D
setTimeout(() => {
if (mapInstance.value) {
mapInstance.value.setPitch(45) // (3D)
}
}, 100)
} else {
mapInstance.value.setPitch(0) //
>>>>>>> 999104f0d0174d3a3870682fb7163d95bc623970
}
//
@ -494,11 +407,7 @@ export default {
if (layerInstances.value.buildings) {
mapInstance.value.remove(layerInstances.value.buildings)
}
<<<<<<< HEAD
layerInstances.value.buildings = new window.AMap.Buildings({
=======
layerInstances.value.buildings = new window.AMap.BuildingLayer({
>>>>>>> 999104f0d0174d3a3870682fb7163d95bc623970
zIndex: 10,
// 3D
heightFactor: mode === '3D' ? 1 : 0.6
@ -510,11 +419,6 @@ export default {
if (currentMapStyle.value === 'satellite') {
changeMapStyle('satellite')
}
<<<<<<< HEAD
=======
console.log(`已切换到${mode}视图模式`)
>>>>>>> 999104f0d0174d3a3870682fb7163d95bc623970
} catch (error) {
console.error('切换视图模式失败:', error)
ElMessage.error(`切换到${mode}视图失败: ${error.message}`)
@ -525,7 +429,6 @@ export default {
const updateOverlayLayers = () => {
if (!mapInstance.value || !window.AMap) return
<<<<<<< HEAD
try {
console.log('更新图层状态:', overlayLayers.value)
@ -716,102 +619,6 @@ export default {
return '<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 24 24" fill="#64B5F6"><circle cx="12" cy="12" r="10" fill-opacity="0.7"/><text x="12" y="16" text-anchor="middle" fill="white" font-size="10">天气</text></svg>'
}
=======
//
if (overlayLayers.value.includes('traffic')) {
if (!layerInstances.value.traffic) {
layerInstances.value.traffic = new window.AMap.TileLayer.Traffic()
mapInstance.value.add(layerInstances.value.traffic)
} else if (!mapInstance.value.getLayers().includes(layerInstances.value.traffic)) {
mapInstance.value.add(layerInstances.value.traffic)
}
} else if (layerInstances.value.traffic) {
mapInstance.value.remove(layerInstances.value.traffic)
}
//
if (overlayLayers.value.includes('buildings')) {
if (!layerInstances.value.buildings) {
layerInstances.value.buildings = new window.AMap.BuildingLayer()
mapInstance.value.add(layerInstances.value.buildings)
} else if (!mapInstance.value.getLayers().includes(layerInstances.value.buildings)) {
mapInstance.value.add(layerInstances.value.buildings)
}
} else if (layerInstances.value.buildings) {
mapInstance.value.remove(layerInstances.value.buildings)
}
// 线
if (overlayLayers.value.includes('terrain')) {
if (!layerInstances.value.terrain) {
//
layerInstances.value.terrain = new window.AMap.TileLayer({
zIndex: 10,
getTileUrl: function(x, y, z) {
// 使
return 'https://webst0' + (x % 4 + 1) + '.is.autonavi.com/appmaptile?style=6&x=' + x + '&y=' + y + '&z=' + z;
}
});
mapInstance.value.add(layerInstances.value.terrain)
} else if (!mapInstance.value.getLayers().includes(layerInstances.value.terrain)) {
mapInstance.value.add(layerInstances.value.terrain)
}
} else if (layerInstances.value.terrain) {
mapInstance.value.remove(layerInstances.value.terrain)
}
//
if (overlayLayers.value.includes('weather')) {
// 使
// 使
if (!layerInstances.value.weather) {
try {
// - 使API
const weatherLayer = new window.AMap.TileLayer({
zIndex: 12,
opacity: 0.6,
getTileUrl: function(x, y, z) {
// URL使API
return `https://api.caiyunapp.com/v1/weatherMap/radar/${z}/${x}/${y}.png`;
}
});
layerInstances.value.weather = weatherLayer;
//
const weatherInfo = new window.AMap.Marker({
position: mapInstance.value.getCenter(),
content: `<div class="weather-info" style="padding: 5px 10px; background: rgba(255,255,255,0.8); border-radius: 4px;">
<div>气温: 26°C</div>
<div>天气: </div>
<div>风力: 3</div>
</div>`,
offset: new window.AMap.Pixel(-60, -40)
});
layerInstances.value.weatherInfo = weatherInfo;
mapInstance.value.add([weatherLayer, weatherInfo]);
} catch (error) {
console.error('天气图层创建失败:', error);
ElMessage.warning('天气信息图层加载失败,请稍后再试');
}
} else if (!mapInstance.value.getLayers().includes(layerInstances.value.weather)) {
if (layerInstances.value.weatherInfo) {
mapInstance.value.add([layerInstances.value.weather, layerInstances.value.weatherInfo]);
} else {
mapInstance.value.add(layerInstances.value.weather);
}
}
} else if (layerInstances.value.weather) {
if (layerInstances.value.weatherInfo) {
mapInstance.value.remove([layerInstances.value.weather, layerInstances.value.weatherInfo]);
} else {
mapInstance.value.remove(layerInstances.value.weather);
}
}
}
>>>>>>> 999104f0d0174d3a3870682fb7163d95bc623970
//
provide('mapInstance', mapInstance)

@ -81,6 +81,9 @@ export default createStore({
// 根级别的无人机mutations
SET_DRONES(state, drones) {
state.drones = drones
},
setDrones(state, drones) {
state.drones = drones
}
},
actions: {

@ -1,649 +0,0 @@
<template>
<div class="path-planning-container">
<!-- 左侧控制面板 -->
<div class="control-panel">
<el-card class="planning-controls">
<template #header>
<div class="card-header">
<span>路径规划</span>
<el-button type="danger" size="small" @click="clearAll">
清除所有
</el-button>
</div>
</template>
<!-- 无人机选择 -->
<div class="section">
<h4>选择无人机</h4>
<el-select v-model="selectedDroneId" placeholder="请选择无人机" style="width: 100%">
<el-option
v-for="drone in drones"
:key="drone.id"
:label="drone.name"
:value="drone.id"
>
<span>{{ drone.name }}</span>
<span style="float: right; color: #8492a6; font-size: 13px">
电量: {{ drone.battery }}%
</span>
</el-option>
</el-select>
</div>
<!-- 路径点列表 -->
<div class="section">
<h4>路径点 ({{ pathPoints.length }})</h4>
<div class="mode-buttons">
<el-button
:type="addMode ? 'success' : 'default'"
size="small"
@click="toggleAddMode"
>
{{ addMode ? '停止添加' : '添加路径点' }}
</el-button>
<el-button type="primary" size="small" @click="planPath" :disabled="pathPoints.length < 2">
规划路径
</el-button>
</div>
<div class="path-points-list">
<div
v-for="(point, index) in pathPoints"
:key="index"
class="path-point-item"
>
<span class="point-index">{{ index + 1 }}</span>
<span class="point-coords">
{{ point.lng.toFixed(4) }}, {{ point.lat.toFixed(4) }}
</span>
<el-button
type="danger"
size="small"
@click="removePoint(index)"
style="margin-left: auto;"
>
删除
</el-button>
</div>
</div>
</div>
<!-- 规划参数 -->
<div class="section">
<h4>规划参数</h4>
<el-form label-width="80px" size="small">
<el-form-item label="算法">
<el-select v-model="planningAlgorithm" style="width: 100%">
<el-option label="A*算法" value="astar" />
<el-option label="RRT算法" value="rrt" />
<el-option label="直线规划" value="straight" />
</el-select>
</el-form-item>
<el-form-item label="飞行高度">
<el-input-number
v-model="flightAltitude"
:min="10"
:max="500"
:step="10"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="飞行速度">
<el-input-number
v-model="flightSpeed"
:min="1"
:max="50"
:step="1"
style="width: 100%"
/>
</el-form-item>
</el-form>
</div>
<!-- 路径信息 -->
<div v-if="pathInfo" class="section">
<h4>路径信息</h4>
<div class="path-info">
<p><strong>总距离:</strong> {{ pathInfo.distance }}</p>
<p><strong>预计时间:</strong> {{ pathInfo.duration }}分钟</p>
<p><strong>路径点数:</strong> {{ pathInfo.pointCount }}</p>
</div>
<el-button type="success" @click="executePath" style="width: 100%">
执行路径
</el-button>
</div>
</el-card>
</div>
<!-- 右侧地图 -->
<div class="map-container">
<div id="planningMap" class="map">
<div v-if="!mapLoaded" class="map-loading">
<el-text>地图加载中...</el-text>
</div>
<div v-if="addMode" class="add-mode-tip">
<el-alert title="点击地图添加路径点" type="info" show-icon />
</div>
<!-- 地图工具栏 -->
<div class="map-toolbar">
<el-button-group>
<el-button @click="centerToStart" :disabled="pathPoints.length === 0">
起点
</el-button>
<el-button @click="centerToEnd" :disabled="pathPoints.length < 2">
终点
</el-button>
<el-button @click="fitToPath" :disabled="pathPoints.length === 0">
适合路径
</el-button>
</el-button-group>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted, onUnmounted } from 'vue'
import { useStore } from 'vuex'
import { ElMessage, ElMessageBox } from 'element-plus'
import AMapLoader from '@amap/amap-jsapi-loader'
export default {
name: 'PathPlanningView',
setup() {
const store = useStore()
const map = ref(null)
const drones = ref([])
const selectedDroneId = ref(null)
const pathPoints = ref([])
const pathMarkers = ref([])
const pathLine = ref(null)
const droneMarkers = ref({})
const mapLoaded = ref(false)
const addMode = ref(false)
const planningAlgorithm = ref('straight')
const flightAltitude = ref(100)
const flightSpeed = ref(10)
const pathInfo = ref(null)
// base64
const safeBase64Encode = (str) => {
try {
return btoa(unescape(encodeURIComponent(str)))
} catch (e) {
const simpleStr = str.replace(/[\u4e00-\u9fa5]/g, 'X')
return btoa(simpleStr)
}
}
const initMap = async () => {
try {
const AMap = await AMapLoader.load({
key: '492dc9daf4eae7cab678c0f3efed8198',
version: '2.0',
plugins: [
'AMap.ToolBar',
'AMap.Scale',
'AMap.HawkEye',
'AMap.MapType',
'AMap.Geolocation',
'AMap.Driving'
]
})
map.value = new AMap.Map('planningMap', {
zoom: 13,
center: [116.397428, 39.90923],
viewMode: '3D',
mapStyle: 'amap://styles/normal'
})
//
map.value.addControl(new AMap.ToolBar({
position: { top: '10px', right: '10px' }
}))
map.value.addControl(new AMap.Scale({
position: { bottom: '10px', right: '10px' }
}))
//
map.value.on('click', onMapClick)
mapLoaded.value = true
initDroneMarkers()
ElMessage.success('路径规划地图加载成功')
} catch (error) {
console.error('地图加载失败:', error)
ElMessage.error('地图加载失败')
}
}
const onMapClick = (e) => {
if (!addMode.value) return
const { lng, lat } = e.lnglat
addPathPoint(lng, lat)
}
const addPathPoint = (lng, lat) => {
const AMap = window.AMap
const pointIndex = pathPoints.value.length + 1
//
pathPoints.value.push({ lng, lat })
//
const marker = new AMap.Marker({
position: [lng, lat],
icon: new AMap.Icon({
size: new AMap.Size(30, 30),
image: 'data:image/svg+xml;base64,' + safeBase64Encode(`
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30">
<circle cx="15" cy="15" r="12" fill="#1890FF" stroke="#fff" stroke-width="2"/>
<text x="15" y="19" text-anchor="middle" fill="white" font-size="10" font-weight="bold">${pointIndex}</text>
</svg>
`),
imageSize: new AMap.Size(30, 30)
}),
title: `路径点${pointIndex}`
})
map.value.add(marker)
pathMarkers.value.push(marker)
// 线
if (pathPoints.value.length > 1) {
updatePathLine()
}
ElMessage.success(`已添加路径点${pointIndex}`)
}
const updatePathLine = () => {
const AMap = window.AMap
if (pathLine.value) {
map.value.remove(pathLine.value)
}
const path = pathPoints.value.map(p => [p.lng, p.lat])
pathLine.value = new AMap.Polyline({
path: path,
strokeColor: '#1890FF',
strokeWeight: 4,
strokeStyle: 'solid'
})
map.value.add(pathLine.value)
}
const removePoint = (index) => {
pathPoints.value.splice(index, 1)
//
clearPathMarkers()
pathPoints.value.forEach((point, i) => {
addPathPointMarker(point.lng, point.lat, i + 1)
})
if (pathPoints.value.length > 1) {
updatePathLine()
} else if (pathLine.value) {
map.value.remove(pathLine.value)
pathLine.value = null
}
}
const addPathPointMarker = (lng, lat, index) => {
const AMap = window.AMap
const marker = new AMap.Marker({
position: [lng, lat],
icon: new AMap.Icon({
size: new AMap.Size(30, 30),
image: 'data:image/svg+xml;base64,' + safeBase64Encode(`
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30">
<circle cx="15" cy="15" r="12" fill="#1890FF" stroke="#fff" stroke-width="2"/>
<text x="15" y="19" text-anchor="middle" fill="white" font-size="10" font-weight="bold">${index}</text>
</svg>
`),
imageSize: new AMap.Size(30, 30)
}),
title: `路径点${index}`
})
map.value.add(marker)
pathMarkers.value.push(marker)
}
const clearPathMarkers = () => {
pathMarkers.value.forEach(marker => {
map.value.remove(marker)
})
pathMarkers.value = []
}
const planPath = async () => {
if (pathPoints.value.length < 2) {
ElMessage.warning('至少需要2个路径点')
return
}
try {
//
const distance = calculateDistance()
const duration = Math.ceil(distance / (flightSpeed.value * 1000 / 60)) //
pathInfo.value = {
distance: Math.round(distance),
duration: duration,
pointCount: pathPoints.value.length
}
ElMessage.success('路径规划完成')
} catch (error) {
ElMessage.error('路径规划失败')
}
}
const calculateDistance = () => {
let totalDistance = 0
for (let i = 0; i < pathPoints.value.length - 1; i++) {
const p1 = pathPoints.value[i]
const p2 = pathPoints.value[i + 1]
totalDistance += getDistance(p1.lat, p1.lng, p2.lat, p2.lng)
}
return totalDistance
}
const getDistance = (lat1, lng1, lat2, lng2) => {
const R = 6371e3
const φ1 = lat1 * Math.PI/180
const φ2 = lat2 * Math.PI/180
const Δφ = (lat2-lat1) * Math.PI/180
const Δλ = (lng2-lng1) * Math.PI/180
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
return R * c
}
const executePath = async () => {
if (!selectedDroneId.value) {
ElMessage.warning('请选择执行路径的无人机')
return
}
try {
await ElMessageBox.confirm('确定要执行此路径吗?', '确认执行', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('路径执行中...')
// TODO:
} catch {
ElMessage.info('已取消执行')
}
}
const initDroneMarkers = async () => {
try {
await store.dispatch('drones/fetchDrones')
drones.value = store.state.drones.list || []
const AMap = window.AMap
if (!AMap || !map.value) return
drones.value.forEach(drone => {
if (drone && typeof drone.longitude === 'number' && typeof drone.latitude === 'number') {
const marker = new AMap.Marker({
position: [drone.longitude, drone.latitude],
title: drone.name,
icon: new AMap.Icon({
size: new AMap.Size(32, 32),
image: getDroneIconSvg(drone.status),
imageSize: new AMap.Size(32, 32)
})
})
droneMarkers.value[drone.id] = marker
map.value.add(marker)
}
})
} catch (error) {
console.error('获取无人机失败:', error)
}
}
const getDroneIconSvg = (status) => {
const colors = {
active: '#52C41A',
idle: '#1890FF',
error: '#FF4D4F'
}
const color = colors[status] || colors.idle
return 'data:image/svg+xml;base64,' + safeBase64Encode(`
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="12" fill="${color}" stroke="#fff" stroke-width="2"/>
<circle cx="16" cy="16" r="6" fill="#fff"/>
</svg>
`)
}
const toggleAddMode = () => {
addMode.value = !addMode.value
ElMessage.info(addMode.value ? '点击地图添加路径点' : '已停止添加模式')
}
const clearAll = () => {
pathPoints.value = []
pathInfo.value = null
clearPathMarkers()
if (pathLine.value) {
map.value.remove(pathLine.value)
pathLine.value = null
}
ElMessage.success('已清除所有路径点')
}
const centerToStart = () => {
if (pathPoints.value.length > 0) {
const start = pathPoints.value[0]
map.value.setCenter([start.lng, start.lat])
map.value.setZoom(16)
}
}
const centerToEnd = () => {
if (pathPoints.value.length > 1) {
const end = pathPoints.value[pathPoints.value.length - 1]
map.value.setCenter([end.lng, end.lat])
map.value.setZoom(16)
}
}
const fitToPath = () => {
if (pathPoints.value.length > 0 && map.value) {
const bounds = new AMap.Bounds()
pathPoints.value.forEach(point => {
bounds.extend([point.lng, point.lat])
})
map.value.setBounds(bounds)
}
}
onMounted(() => {
initMap()
})
onUnmounted(() => {
if (map.value) {
map.value.destroy()
}
})
return {
drones,
selectedDroneId,
pathPoints,
mapLoaded,
addMode,
planningAlgorithm,
flightAltitude,
flightSpeed,
pathInfo,
toggleAddMode,
removePoint,
planPath,
executePath,
clearAll,
centerToStart,
centerToEnd,
fitToPath
}
}
}
</script>
<style lang="scss" scoped>
.path-planning-container {
display: flex;
height: calc(100vh - 60px);
}
.control-panel {
width: 350px;
background: #f5f5f5;
border-right: 1px solid #e8e8e8;
overflow-y: auto;
.planning-controls {
height: 100%;
margin: 0;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.section {
margin-bottom: 20px;
h4 {
margin: 0 0 10px 0;
color: #333;
font-size: 14px;
font-weight: 600;
}
}
.mode-buttons {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.path-points-list {
max-height: 200px;
overflow-y: auto;
.path-point-item {
display: flex;
align-items: center;
padding: 8px;
margin: 4px 0;
background: #fff;
border-radius: 4px;
border: 1px solid #e8e8e8;
.point-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: #1890FF;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
margin-right: 8px;
}
.point-coords {
flex: 1;
font-size: 12px;
color: #666;
}
}
}
.path-info {
background: #f9f9f9;
padding: 12px;
border-radius: 4px;
margin-bottom: 10px;
p {
margin: 4px 0;
font-size: 13px;
color: #666;
}
}
}
.map-container {
flex: 1;
position: relative;
.map {
width: 100%;
height: 100%;
position: relative;
.map-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background: rgba(255, 255, 255, 0.9);
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.add-mode-tip {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
}
.map-toolbar {
position: absolute;
top: 10px;
right: 120px;
z-index: 1000;
}
}
}
#planningMap {
width: 100%;
height: 100%;
}
</style>

@ -1,921 +0,0 @@
<template>
<div class="threat-zone-container">
<!-- 左侧控制面板 -->
<div class="control-panel">
<el-card class="threat-controls">
<template #header>
<div class="card-header">
<span>威胁区设置</span>
<el-button type="danger" size="small" @click="clearAllZones">
清除所有
</el-button>
</div>
</template>
<!-- 威胁区类型选择 -->
<div class="section">
<h4>威胁区类型</h4>
<el-radio-group v-model="currentThreatType" @change="onThreatTypeChange">
<el-radio value="radar">雷达威胁</el-radio>
<el-radio value="missile">导弹威胁</el-radio>
<el-radio value="aircraft">空中威胁</el-radio>
<el-radio value="ground">地面威胁</el-radio>
<el-radio value="weather">气象威胁</el-radio>
</el-radio-group>
</div>
<!-- 绘制工具 -->
<div class="section">
<h4>绘制工具</h4>
<div class="draw-tools">
<el-button
:type="drawMode === 'circle' ? 'primary' : 'default'"
size="small"
@click="setDrawMode('circle')"
>
圆形区域
</el-button>
<el-button
:type="drawMode === 'polygon' ? 'primary' : 'default'"
size="small"
@click="setDrawMode('polygon')"
>
多边形区域
</el-button>
<el-button
:type="drawMode === 'rectangle' ? 'primary' : 'default'"
size="small"
@click="setDrawMode('rectangle')"
>
矩形区域
</el-button>
</div>
<div v-if="drawMode" class="draw-tip">
<el-alert
:title="getDrawTip()"
type="info"
show-icon
:closable="false"
/>
</div>
</div>
<!-- 威胁参数设置 -->
<div class="section">
<h4>威胁参数</h4>
<el-form label-width="80px" size="small">
<el-form-item label="威胁类型">
<el-radio-group v-model="threatLevel" @change="onThreatTypeChange">
<el-radio value="low"></el-radio>
<el-radio value="medium"></el-radio>
<el-radio value="high"></el-radio>
<el-radio value="critical">严重</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="半径(米)" v-if="drawMode === 'circle'">
<el-input-number
v-model="circleRadius"
:min="100"
:max="10000"
:step="100"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="威胁描述">
<el-input
v-model="threatDescription"
type="textarea"
:rows="2"
placeholder="输入威胁描述..."
/>
</el-form-item>
<el-form-item label="有效时间">
<el-date-picker
v-model="threatTimeRange"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-form>
</div>
<!-- 威胁区列表 -->
<div class="section">
<h4>威胁区列表 ({{ threatZones.length }})</h4>
<div class="zones-list">
<div
v-for="zone in threatZones"
:key="zone.id"
class="zone-item"
:class="{ active: selectedZoneId === zone.id }"
@click="selectZone(zone.id)"
>
<div class="zone-header">
<span class="zone-type">{{ getThreatTypeName(zone.type) }}</span>
<span :class="['zone-level', zone.level]">{{ getThreatLevelName(zone.level) }}</span>
</div>
<div class="zone-description">{{ zone.description || '无描述' }}</div>
<div class="zone-actions">
<el-button type="primary" size="small" @click.stop="editZone(zone)">
编辑
</el-button>
<el-button type="danger" size="small" @click.stop="deleteZone(zone.id)">
删除
</el-button>
</div>
</div>
</div>
</div>
<!-- 图层控制 -->
<div class="section">
<h4>图层显示</h4>
<el-checkbox-group v-model="visibleLayers" @change="updateLayerVisibility">
<el-checkbox value="radar">雷达威胁</el-checkbox>
<el-checkbox value="missile">导弹威胁</el-checkbox>
<el-checkbox value="aircraft">空中威胁</el-checkbox>
<el-checkbox value="ground">地面威胁</el-checkbox>
<el-checkbox value="weather">气象威胁</el-checkbox>
</el-checkbox-group>
</div>
</el-card>
</div>
<!-- 右侧地图 -->
<div class="map-container">
<div id="threatMap" class="map">
<div v-if="!mapLoaded" class="map-loading">
<el-text>地图加载中...</el-text>
</div>
<!-- 地图工具栏 -->
<div class="map-toolbar">
<el-button-group>
<el-button @click="fitToZones" :disabled="threatZones.length === 0">
适合威胁区
</el-button>
<el-button @click="exportZones" :disabled="threatZones.length === 0">
导出数据
</el-button>
<el-button @click="importZones">
导入数据
</el-button>
</el-button-group>
</div>
<!-- 图例 -->
<div class="map-legend">
<div class="legend-title">威胁等级图例</div>
<div class="legend-items">
<div class="legend-item">
<span class="legend-color low"></span>
<span>低威胁</span>
</div>
<div class="legend-item">
<span class="legend-color medium"></span>
<span>中威胁</span>
</div>
<div class="legend-item">
<span class="legend-color high"></span>
<span>高威胁</span>
</div>
<div class="legend-item">
<span class="legend-color critical"></span>
<span>极高威胁</span>
</div>
</div>
</div>
</div>
</div>
<!-- 编辑威胁区对话框 -->
<el-dialog
v-model="editDialogVisible"
title="编辑威胁区"
width="400px"
>
<el-form :model="editForm" label-width="80px" size="small">
<el-form-item label="威胁类型">
<el-select v-model="editForm.type" style="width: 100%">
<el-option label="雷达威胁" value="radar" />
<el-option label="导弹威胁" value="missile" />
<el-option label="空中威胁" value="aircraft" />
<el-option label="地面威胁" value="ground" />
<el-option label="气象威胁" value="weather" />
</el-select>
</el-form-item>
<el-form-item label="威胁等级">
<el-select v-model="editForm.level" style="width: 100%">
<el-option label="低威胁" value="low" />
<el-option label="中威胁" value="medium" />
<el-option label="高威胁" value="high" />
<el-option label="极高威胁" value="critical" />
</el-select>
</el-form-item>
<el-form-item label="威胁描述">
<el-input
v-model="editForm.description"
type="textarea"
:rows="3"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveEditedZone"></el-button>
</template>
</el-dialog>
</div>
</template>
<script>
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AMapLoader from '@amap/amap-jsapi-loader'
export default {
name: 'ThreatZoneView',
setup() {
const map = ref(null)
const mapLoaded = ref(false)
const threatZones = ref([])
const zoneOverlays = ref({})
const drawMode = ref(null)
const currentThreatType = ref('radar')
const threatLevel = ref('medium')
const circleRadius = ref(1000)
const threatDescription = ref('')
const threatTimeRange = ref([])
const selectedZoneId = ref(null)
const visibleLayers = ref(['radar', 'missile', 'aircraft', 'ground', 'weather'])
const editDialogVisible = ref(false)
const editForm = ref({})
const currentDrawing = ref(null)
//
const threatTypeConfig = {
radar: { name: '雷达威胁', color: '#FF6B6B' },
missile: { name: '导弹威胁', color: '#FF4757' },
aircraft: { name: '空中威胁', color: '#5352ED' },
ground: { name: '地面威胁', color: '#20BF6B' },
weather: { name: '气象威胁', color: '#A55EEA' }
}
//
const threatLevelConfig = {
low: { name: '低威胁', opacity: 0.3 },
medium: { name: '中威胁', opacity: 0.5 },
high: { name: '高威胁', opacity: 0.7 },
critical: { name: '极高威胁', opacity: 0.9 }
}
// base64
const safeBase64Encode = (str) => {
try {
return btoa(unescape(encodeURIComponent(str)))
} catch (e) {
const simpleStr = str.replace(/[\u4e00-\u9fa5]/g, 'X')
return btoa(simpleStr)
}
}
const initMap = async () => {
try {
const AMap = await AMapLoader.load({
key: '492dc9daf4eae7cab678c0f3efed8198',
version: '2.0',
plugins: [
'AMap.ToolBar',
'AMap.Scale',
'AMap.HawkEye',
'AMap.MapType',
'AMap.MouseTool',
'AMap.PolyEditor'
]
})
map.value = new AMap.Map('threatMap', {
zoom: 11,
center: [116.397428, 39.90923],
viewMode: '3D',
mapStyle: 'amap://styles/normal'
})
//
map.value.addControl(new AMap.ToolBar({
position: { top: '10px', right: '10px' }
}))
map.value.addControl(new AMap.Scale({
position: { bottom: '10px', right: '10px' }
}))
//
const mouseTool = new AMap.MouseTool(map.value)
currentDrawing.value = mouseTool
//
mouseTool.on('draw', onDrawComplete)
mapLoaded.value = true
loadSampleThreatZones()
ElMessage.success('威胁区地图加载成功')
} catch (error) {
console.error('地图加载失败:', error)
ElMessage.error('地图加载失败')
}
}
const setDrawMode = (mode) => {
if (drawMode.value === mode) {
//
drawMode.value = null
if (currentDrawing.value) {
currentDrawing.value.close(true)
}
ElMessage.info('已取消绘制模式')
return
}
drawMode.value = mode
if (!currentDrawing.value) return
//
switch (mode) {
case 'circle':
currentDrawing.value.circle({
strokeColor: threatTypeConfig[currentThreatType.value].color,
strokeWeight: 2,
fillColor: threatTypeConfig[currentThreatType.value].color,
fillOpacity: threatLevelConfig[threatLevel.value].opacity
})
break
case 'polygon':
currentDrawing.value.polygon({
strokeColor: threatTypeConfig[currentThreatType.value].color,
strokeWeight: 2,
fillColor: threatTypeConfig[currentThreatType.value].color,
fillOpacity: threatLevelConfig[threatLevel.value].opacity
})
break
case 'rectangle':
currentDrawing.value.rectangle({
strokeColor: threatTypeConfig[currentThreatType.value].color,
strokeWeight: 2,
fillColor: threatTypeConfig[currentThreatType.value].color,
fillOpacity: threatLevelConfig[threatLevel.value].opacity
})
break
}
ElMessage.info(getDrawTip())
}
const onDrawComplete = (event) => {
const overlay = event.obj
//
const zone = {
id: Date.now(),
type: currentThreatType.value,
level: threatLevel.value,
description: threatDescription.value,
timeRange: threatTimeRange.value,
geometry: getGeometryFromOverlay(overlay),
overlay: overlay
}
//
threatZones.value.push(zone)
zoneOverlays.value[zone.id] = overlay
//
overlay.on('click', () => selectZone(zone.id))
//
drawMode.value = null
if (currentDrawing.value) {
currentDrawing.value.close(true)
}
ElMessage.success(`已创建${threatTypeConfig[currentThreatType.value].name}区域`)
}
const getGeometryFromOverlay = (overlay) => {
if (overlay.CLASS_NAME === 'AMap.Circle') {
return {
type: 'circle',
center: overlay.getCenter(),
radius: overlay.getRadius()
}
} else if (overlay.CLASS_NAME === 'AMap.Polygon') {
return {
type: 'polygon',
path: overlay.getPath()
}
} else if (overlay.CLASS_NAME === 'AMap.Rectangle') {
return {
type: 'rectangle',
bounds: overlay.getBounds()
}
}
return null
}
const getDrawTip = () => {
const tips = {
circle: '点击地图中心点,拖拽设置半径',
polygon: '点击地图添加顶点,双击完成绘制',
rectangle: '点击并拖拽绘制矩形区域'
}
return tips[drawMode.value] || ''
}
const getThreatTypeName = (type) => {
return threatTypeConfig[type]?.name || type
}
const getThreatLevelName = (level) => {
return threatLevelConfig[level]?.name || level
}
const onThreatTypeChange = () => {
//
if (drawMode.value && currentDrawing.value) {
setDrawMode(drawMode.value)
}
}
const selectZone = (zoneId) => {
selectedZoneId.value = zoneId
const zone = threatZones.value.find(z => z.id === zoneId)
if (zone && zone.overlay) {
//
zone.overlay.setOptions({
strokeWeight: 4,
strokeStyle: 'dashed'
})
//
threatZones.value.forEach(z => {
if (z.id !== zoneId && z.overlay) {
z.overlay.setOptions({
strokeWeight: 2,
strokeStyle: 'solid'
})
}
})
}
}
const editZone = (zone) => {
editForm.value = {
id: zone.id,
type: zone.type,
level: zone.level,
description: zone.description
}
editDialogVisible.value = true
}
const saveEditedZone = () => {
const zoneIndex = threatZones.value.findIndex(z => z.id === editForm.value.id)
if (zoneIndex !== -1) {
const zone = threatZones.value[zoneIndex]
zone.type = editForm.value.type
zone.level = editForm.value.level
zone.description = editForm.value.description
//
if (zone.overlay) {
const typeConfig = threatTypeConfig[zone.type]
const levelConfig = threatLevelConfig[zone.level]
zone.overlay.setOptions({
strokeColor: typeConfig.color,
fillColor: typeConfig.color,
fillOpacity: levelConfig.opacity
})
}
updateLayerVisibility()
ElMessage.success('威胁区已更新')
}
editDialogVisible.value = false
}
const deleteZone = async (zoneId) => {
try {
await ElMessageBox.confirm('确定要删除此威胁区吗?', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const zoneIndex = threatZones.value.findIndex(z => z.id === zoneId)
if (zoneIndex !== -1) {
const zone = threatZones.value[zoneIndex]
if (zone.overlay) {
map.value.remove(zone.overlay)
}
delete zoneOverlays.value[zoneId]
threatZones.value.splice(zoneIndex, 1)
if (selectedZoneId.value === zoneId) {
selectedZoneId.value = null
}
ElMessage.success('威胁区已删除')
}
} catch {
ElMessage.info('已取消删除')
}
}
const clearAllZones = async () => {
if (threatZones.value.length === 0) {
ElMessage.warning('没有威胁区需要清除')
return
}
try {
await ElMessageBox.confirm('确定要清除所有威胁区吗?', '确认清除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
threatZones.value.forEach(zone => {
if (zone.overlay) {
map.value.remove(zone.overlay)
}
})
threatZones.value = []
zoneOverlays.value = {}
selectedZoneId.value = null
ElMessage.success('已清除所有威胁区')
} catch {
ElMessage.info('已取消清除')
}
}
const updateLayerVisibility = () => {
threatZones.value.forEach(zone => {
if (zone.overlay) {
if (visibleLayers.value.includes(zone.type)) {
zone.overlay.show()
} else {
zone.overlay.hide()
}
}
})
}
const fitToZones = () => {
if (threatZones.value.length === 0) return
const bounds = new AMap.Bounds()
threatZones.value.forEach(zone => {
if (zone.overlay && visibleLayers.value.includes(zone.type)) {
const overlayBounds = zone.overlay.getBounds()
if (overlayBounds) {
bounds.extend(overlayBounds)
}
}
})
map.value.setBounds(bounds)
}
const exportZones = () => {
const exportData = threatZones.value.map(zone => ({
id: zone.id,
type: zone.type,
level: zone.level,
description: zone.description,
timeRange: zone.timeRange,
geometry: zone.geometry
}))
const dataStr = JSON.stringify(exportData, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = `threat_zones_${new Date().toISOString().slice(0, 10)}.json`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('威胁区数据已导出')
}
const importZones = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = (event) => {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const importData = JSON.parse(e.target.result)
// TODO:
ElMessage.success('威胁区数据已导入')
} catch (error) {
ElMessage.error('导入失败,文件格式错误')
}
}
reader.readAsText(file)
}
input.click()
}
const loadSampleThreatZones = () => {
//
const sampleZones = [
{
id: 1,
type: 'radar',
level: 'high',
description: '敌方雷达基站',
center: [116.397428, 39.91],
radius: 2000
},
{
id: 2,
type: 'missile',
level: 'critical',
description: '导弹发射阵地',
center: [116.42, 39.89],
radius: 3000
}
]
const AMap = window.AMap
if (!AMap || !map.value) return
sampleZones.forEach(zoneData => {
const overlay = new AMap.Circle({
center: zoneData.center,
radius: zoneData.radius,
strokeColor: threatTypeConfig[zoneData.type].color,
strokeWeight: 2,
fillColor: threatTypeConfig[zoneData.type].color,
fillOpacity: threatLevelConfig[zoneData.level].opacity
})
map.value.add(overlay)
const zone = {
...zoneData,
geometry: {
type: 'circle',
center: zoneData.center,
radius: zoneData.radius
},
overlay: overlay
}
threatZones.value.push(zone)
zoneOverlays.value[zone.id] = overlay
overlay.on('click', () => selectZone(zone.id))
})
}
onMounted(() => {
initMap()
})
onUnmounted(() => {
if (map.value) {
map.value.destroy()
}
})
return {
mapLoaded,
threatZones,
drawMode,
currentThreatType,
threatLevel,
circleRadius,
threatDescription,
threatTimeRange,
selectedZoneId,
visibleLayers,
editDialogVisible,
editForm,
setDrawMode,
getDrawTip,
getThreatTypeName,
getThreatLevelName,
onThreatTypeChange,
selectZone,
editZone,
saveEditedZone,
deleteZone,
clearAllZones,
updateLayerVisibility,
fitToZones,
exportZones,
importZones
}
}
}
</script>
<style lang="scss" scoped>
.threat-zone-container {
display: flex;
height: calc(100vh - 60px);
}
.control-panel {
width: 380px;
background: #f5f5f5;
border-right: 1px solid #e8e8e8;
overflow-y: auto;
.threat-controls {
height: 100%;
margin: 0;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.section {
margin-bottom: 20px;
h4 {
margin: 0 0 10px 0;
color: #333;
font-size: 14px;
font-weight: 600;
}
}
.draw-tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.draw-tip {
margin-top: 10px;
}
.zones-list {
max-height: 300px;
overflow-y: auto;
.zone-item {
padding: 12px;
margin: 8px 0;
background: #fff;
border-radius: 6px;
border: 2px solid #e8e8e8;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #1890FF;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2);
}
&.active {
border-color: #1890FF;
background: #f6ffed;
}
.zone-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.zone-type {
font-weight: 600;
color: #333;
}
.zone-level {
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: white;
&.low { background: #52c41a; }
&.medium { background: #faad14; }
&.high { background: #fa8c16; }
&.critical { background: #f5222d; }
}
}
.zone-description {
font-size: 12px;
color: #666;
margin-bottom: 8px;
line-height: 1.4;
}
.zone-actions {
display: flex;
gap: 6px;
}
}
}
}
.map-container {
flex: 1;
position: relative;
.map {
width: 100%;
height: 100%;
position: relative;
.map-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background: rgba(255, 255, 255, 0.9);
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.map-toolbar {
position: absolute;
top: 10px;
right: 120px;
z-index: 1000;
}
.map-legend {
position: absolute;
bottom: 60px;
left: 10px;
background: rgba(255, 255, 255, 0.9);
padding: 12px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1000;
.legend-title {
font-weight: 600;
margin-bottom: 8px;
color: #333;
font-size: 12px;
}
.legend-items {
.legend-item {
display: flex;
align-items: center;
margin: 4px 0;
font-size: 11px;
color: #666;
.legend-color {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 6px;
&.low { background: #52c41a; }
&.medium { background: #faad14; }
&.high { background: #fa8c16; }
&.critical { background: #f5222d; }
}
}
}
}
}
}
#threatMap {
width: 100%;
height: 100%;
}
</style>
Loading…
Cancel
Save