Compare commits

...

7 Commits

Author SHA1 Message Date
cuitw 4c41c3a555 小的优化 5 days ago
cuitw 06f7245f7a Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 5 days ago
menghao f03181867d 拖拽航点后平台乱移bug修复 5 days ago
menghao 8f1694ea51 拖拽航点后平台乱移bug修复 5 days ago
menghao 8eaad72df3 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 6 days ago
menghao 696e395a02 弹窗样式修改 6 days ago
menghao 69fc744994 坐标生成空域、空域上锁 6 days ago
  1. 94
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomPlatformIconController.java
  2. 8
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/RouteWaypointsMapper.java
  3. 5
      ruoyi-system/src/main/java/com/ruoyi/system/service/IRoomPlatformIconService.java
  4. 5
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RoomPlatformIconServiceImpl.java
  5. 11
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysMenuServiceImpl.java
  6. 4
      ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml
  7. 19
      ruoyi-ui/src/api/system/roomPlatformIcon.js
  8. 40
      ruoyi-ui/src/assets/styles/element-ui.scss
  9. 36
      ruoyi-ui/src/lang/en.js
  10. 36
      ruoyi-ui/src/lang/zh.js
  11. 41
      ruoyi-ui/src/store/modules/permission.js
  12. 84
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  13. 608
      ruoyi-ui/src/views/cesiumMap/index.vue
  14. 33
      ruoyi-ui/src/views/childRoom/GanttDrawer.vue
  15. 37
      ruoyi-ui/src/views/childRoom/SixStepsOverlay.vue
  16. 5
      ruoyi-ui/src/views/childRoom/TopHeader.vue
  17. 732
      ruoyi-ui/src/views/childRoom/index.vue
  18. 402
      ruoyi-ui/src/views/dialogs/GenerateAirspaceDialog.vue
  19. 7
      ruoyi-ui/src/views/dialogs/RouteEditDialog.vue
  20. 13
      ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue

94
ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomPlatformIconController.java

@ -10,6 +10,7 @@ import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.domain.RoomPlatformIcon;
import com.ruoyi.system.service.IObjectOperationLogService;
@ -32,16 +33,46 @@ public class RoomPlatformIconController extends BaseController {
private IObjectOperationLogService objectOperationLogService;
/**
* 按房间ID查询房间下所有地图平台图标分页
* 查询房间地图平台图标列表分页
*/
@PreAuthorize("@ss.hasPermi('system:roomPlatformIcon:list')")
@GetMapping("/list")
public AjaxResult list(@RequestParam Long roomId) {
public TableDataInfo list(RoomPlatformIcon roomPlatformIcon) {
// 兜底:若前端将未选择的筛选值传为 0,则按“未传入”处理,避免 where room_id=0 导致空表
if (roomPlatformIcon != null) {
if (roomPlatformIcon.getRoomId() != null && roomPlatformIcon.getRoomId() <= 0) {
roomPlatformIcon.setRoomId(null);
}
if (roomPlatformIcon.getPlatformId() != null && roomPlatformIcon.getPlatformId() <= 0) {
roomPlatformIcon.setPlatformId(null);
}
}
startPage();
List<RoomPlatformIcon> list = roomPlatformIconService.selectRoomPlatformIconList(roomPlatformIcon);
return getDataTable(list);
}
/**
* 按房间ID查询该房间下所有地图平台图标不分页用于 Cesium 地图渲染
*/
@PreAuthorize("@ss.hasPermi('system:roomPlatformIcon:list')")
@GetMapping("/listByRoomId")
public AjaxResult listByRoomId(@RequestParam Long roomId) {
List<RoomPlatformIcon> list = roomPlatformIconService.selectListByRoomId(roomId);
return success(list);
}
/**
* 查询房间地图平台图标详细信息
*/
@PreAuthorize("@ss.hasPermi('system:roomPlatformIcon:list')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable Long id) {
RoomPlatformIcon icon = roomPlatformIconService.selectById(id);
return success(icon);
}
/**
* 新增
*/
@PreAuthorize("@ss.hasPermi('system:roomPlatformIcon:add')")
@ -77,7 +108,7 @@ public class RoomPlatformIconController extends BaseController {
@PutMapping
public AjaxResult edit(@RequestBody RoomPlatformIcon roomPlatformIcon) {
RoomPlatformIcon before = roomPlatformIcon.getId() != null
? roomPlatformIconService.selectById(roomPlatformIcon.getId()) : null;
? roomPlatformIconService.selectById(roomPlatformIcon.getId()) : null;
int rows = roomPlatformIconService.update(roomPlatformIcon);
if (rows > 0 && before != null) {
RoomPlatformIcon after = roomPlatformIconService.selectById(roomPlatformIcon.getId());
@ -106,32 +137,39 @@ public class RoomPlatformIconController extends BaseController {
*/
@PreAuthorize("@ss.hasPermi('system:roomPlatformIcon:remove')")
@Log(title = "房间地图平台图标", businessType = BusinessType.DELETE)
@DeleteMapping("/{id}")
public AjaxResult remove(@PathVariable Long id) {
RoomPlatformIcon icon = roomPlatformIconService.selectById(id);
if (icon != null) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(icon.getRoomId());
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_DELETE);
opLog.setObjectType(ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON);
opLog.setObjectId(String.valueOf(id));
opLog.setObjectName(icon.getPlatformName());
opLog.setDetail("删除地图上的平台:" + (icon.getPlatformName() != null ? icon.getPlatformName() : "") + "(实例ID=" + id + ")");
opLog.setSnapshotBefore(JSON.toJSONString(icon));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录房间地图平台删除操作日志失败", e);
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids) {
int rows = 0;
if (ids == null || ids.length == 0) return success();
for (Long id : ids) {
if (id == null) continue;
RoomPlatformIcon icon = roomPlatformIconService.selectById(id);
if (icon != null && icon.getRoomId() != null) {
String key = "room:" + icon.getRoomId() + ":platformIcons:platforms";
redisTemplate.opsForHash().delete(key, String.valueOf(id));
String oldKey = "room:" + icon.getRoomId() + ":route:0:platforms";
redisTemplate.opsForHash().delete(oldKey, String.valueOf(id));
}
rows += roomPlatformIconService.deleteById(id);
if (icon != null) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(icon.getRoomId());
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_DELETE);
opLog.setObjectType(ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON);
opLog.setObjectId(String.valueOf(id));
opLog.setObjectName(icon.getPlatformName());
opLog.setDetail("删除地图上的平台:" + (icon.getPlatformName() != null ? icon.getPlatformName() : "") + "(实例ID=" + id + ")");
opLog.setSnapshotBefore(JSON.toJSONString(icon));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录房间地图平台删除操作日志失败", e);
}
}
}
if (icon != null && icon.getRoomId() != null) {
String key = "room:" + icon.getRoomId() + ":platformIcons:platforms";
redisTemplate.opsForHash().delete(key, String.valueOf(id));
String oldKey = "room:" + icon.getRoomId() + ":route:0:platforms";
redisTemplate.opsForHash().delete(oldKey, String.valueOf(id));
}
return toAjax(roomPlatformIconService.deleteById(id));
return toAjax(rows);
}
}

8
ruoyi-system/src/main/java/com/ruoyi/system/mapper/RouteWaypointsMapper.java

@ -29,7 +29,7 @@ public interface RouteWaypointsMapper
public List<RouteWaypoints> selectRouteWaypointsList(RouteWaypoints routeWaypoints);
/** 查询指定航线下最大的序号 */
public Integer selectMaxSeqByRouteId(Long routeId);
public Integer selectMaxSeqByRouteId(@Param("routeId") Long routeId);
/** 将指定航线中 seq >= targetSeq 的航点序号均加 1,用于在指定位置插入新航点 */
int incrementSeqFrom(@Param("routeId") Long routeId, @Param("seq") Long targetSeq);
@ -56,7 +56,7 @@ public interface RouteWaypointsMapper
* @param id 航线具体航点明细主键
* @return 结果
*/
public int deleteRouteWaypointsById(Long id);
public int deleteRouteWaypointsById(@Param("id") Long id);
/**
* 删除航线具体航点明细
@ -64,7 +64,7 @@ public interface RouteWaypointsMapper
* @param routeId 航线主键
* @return 结果
*/
public int deleteRouteWaypointsByRouteId(Long routeId);
public int deleteRouteWaypointsByRouteId(@Param("routeId") Long routeId);
/**
* 批量删除航线具体航点明细
@ -72,5 +72,5 @@ public interface RouteWaypointsMapper
* @param ids 需要删除的数据主键集合
* @return 结果
*/
public int deleteRouteWaypointsByIds(Long[] ids);
public int deleteRouteWaypointsByIds(@Param("ids") Long[] ids);
}

5
ruoyi-system/src/main/java/com/ruoyi/system/service/IRoomPlatformIconService.java

@ -10,6 +10,11 @@ public interface IRoomPlatformIconService {
List<RoomPlatformIcon> selectListByRoomId(Long roomId);
/**
* 查询房间地图平台图标列表支持按 roomId/platformId 过滤由分页控制
*/
List<RoomPlatformIcon> selectRoomPlatformIconList(RoomPlatformIcon roomPlatformIcon);
RoomPlatformIcon selectById(Long id);
int insert(RoomPlatformIcon roomPlatformIcon);

5
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RoomPlatformIconServiceImpl.java

@ -25,6 +25,11 @@ public class RoomPlatformIconServiceImpl implements IRoomPlatformIconService {
}
@Override
public List<RoomPlatformIcon> selectRoomPlatformIconList(RoomPlatformIcon roomPlatformIcon) {
return roomPlatformIconMapper.selectRoomPlatformIconList(roomPlatformIcon);
}
@Override
public RoomPlatformIcon selectById(Long id) {
return roomPlatformIconMapper.selectRoomPlatformIconById(id);
}

11
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysMenuServiceImpl.java

@ -462,6 +462,17 @@ public class SysMenuServiceImpl implements ISysMenuService
{
component = menu.getComponent();
}
// 兜底:二级菜单(menuType=C)若 component 为空,会导致前端无法加载页面(白屏且无任何请求)
// 按若依约定:system/<path>/index
else if (StringUtils.isEmpty(menu.getComponent())
&& menu.getParentId().intValue() != MENU_ROOT_ID
&& UserConstants.TYPE_MENU.equals(menu.getMenuType())
&& StringUtils.isNotEmpty(menu.getPath())
&& !isInnerLink(menu)
&& !isParentView(menu))
{
component = "system/" + menu.getPath() + "/index";
}
else if (StringUtils.isEmpty(menu.getComponent()) && menu.getParentId().intValue() != MENU_ROOT_ID && isInnerLink(menu))
{
component = UserConstants.INNER_LINK;

4
ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml

@ -114,9 +114,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
delete from route_waypoints where route_id = #{routeId}
</delete>
<delete id="deleteRouteWaypointsByIds" parameterType="String">
<delete id="deleteRouteWaypointsByIds">
delete from route_waypoints where id in
<foreach item="id" collection="array" open="(" separator="," close=")">
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</delete>

19
ruoyi-ui/src/api/system/roomPlatformIcon.js

@ -3,12 +3,29 @@ import request from '@/utils/request'
/** 按房间ID查询该房间下所有地图平台图标 */
export function listByRoomId(roomId) {
return request({
url: '/system/roomPlatformIcon/list',
url: '/system/roomPlatformIcon/listByRoomId',
method: 'get',
params: { roomId }
})
}
/** 查询房间地图平台图标列表(分页) */
export function listRoomPlatformIcon(query) {
return request({
url: '/system/roomPlatformIcon/list',
method: 'get',
params: query
})
}
/** 查询房间地图平台图标详细 */
export function getRoomPlatformIcon(id) {
return request({
url: '/system/roomPlatformIcon/' + id,
method: 'get'
})
}
/** 新增房间地图平台图标 */
export function addRoomPlatformIcon(data) {
return request({

40
ruoyi-ui/src/assets/styles/element-ui.scss

@ -47,11 +47,51 @@
}
// to fixed https://github.com/ElemeFE/element/issues/2461
// 编辑航线自定义面板一致白底8px 圆角标题字重与头部分割线
.el-dialog {
transform: none;
left: 0;
position: relative;
margin: 0 auto;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
background: #fff;
}
.el-dialog__header {
padding: 14px 20px 12px;
border-bottom: 1px solid #ebeef5;
}
.el-dialog__title {
font-size: 16px;
font-weight: 700;
color: #303133;
line-height: 1.4;
}
.el-dialog__headerbtn {
top: 14px;
right: 16px;
}
.el-dialog__headerbtn .el-dialog__close {
color: #909399;
}
.el-dialog__headerbtn:focus .el-dialog__close,
.el-dialog__headerbtn:hover .el-dialog__close {
color: #606266;
}
.el-dialog__body {
padding: 12px 20px 16px;
}
.el-dialog__footer {
padding: 10px 20px 16px;
border-top: 1px solid #ebeef5;
}
// refine element ui upload

36
ruoyi-ui/src/lang/en.js

@ -53,7 +53,8 @@ export default {
},
airspace: {
powerZone: 'Power Zone',
threatZone: 'Threat Zone'
threatZone: 'Threat Zone',
generateAirspace: 'Generate Airspace'
},
options: {
@ -181,5 +182,38 @@ export default {
sendImage: 'Send image',
imageUploadFailed: 'Image upload failed, please try again',
imageTooLarge: 'Image must be 5MB or smaller'
},
generateAirspace: {
title: 'Generate Airspace',
shapeType: 'Shape',
polygon: 'Polygon',
rectangle: 'Rectangle',
circle: 'Circle',
sector: 'Sector',
name: 'Name',
namePlaceholder: 'Label on map (optional)',
color: 'Fill color',
borderWidth: 'Outline width',
vertices: 'Vertices',
polygonPlaceholder: 'At least 3 vertices, decimal degrees. One pair per line, or one line: (121.47,31.23), (120.15,30.28) separated by comma/semicolon',
rectangleSwCorner: 'SW corner (lon, lat)',
rectangleNeCorner: 'NE corner (lon, lat)',
cornerLonLatPlaceholder: '(longitude, latitude) e.g. (116.39, 39.90)',
centerLonLat: 'Center (lon, lat)',
radiusM: 'Radius',
radiusUnit: 'km',
startBearing: 'Start bearing (°)',
endBearing: 'End bearing (°)',
cancel: 'Cancel',
confirm: 'Create',
defaultLabel: 'Airspace',
errPolygonPoints: 'Polygon needs at least 3 valid lng,lat vertices',
errRectNumbers: 'Enter SW and NE corners as (longitude, latitude)',
errCircle: 'Enter center as (longitude, latitude) and radius in km',
errSector: 'Enter center as (longitude, latitude) and radius in km',
errBearing: 'Enter valid bearings',
needRoom: 'Enter a mission room first',
successMsg: 'Airspace created; it will be saved with the room',
errImport: 'Failed to create; check coordinates and parameters'
}
}

36
ruoyi-ui/src/lang/zh.js

@ -53,7 +53,8 @@ export default {
},
airspace: {
powerZone: '威力区',
threatZone: '威胁区'
threatZone: '威胁区',
generateAirspace: '生成空域'
},
options: {
@ -181,5 +182,38 @@ export default {
sendImage: '发送图片',
imageUploadFailed: '图片上传失败,请重试',
imageTooLarge: '图片不能超过 5MB'
},
generateAirspace: {
title: '生成空域',
shapeType: '形状类型',
polygon: '多边形',
rectangle: '矩形',
circle: '圆形',
sector: '扇形',
name: '名称',
namePlaceholder: '地图上显示的名称(可选)',
color: '填充颜色',
borderWidth: '边线宽度',
vertices: '顶点坐标',
polygonPlaceholder: '至少3个顶点,可每行一对(经度,纬度)或用顿号分隔',
rectangleSwCorner: '西南角经纬度',
rectangleNeCorner: '东北角经纬度',
cornerLonLatPlaceholder: '(经度,纬度)例如 (116.39, 39.90)',
centerLonLat: '圆心经纬度',
radiusM: '半径',
radiusUnit: '千米',
startBearing: '起始方位角(°)',
endBearing: '终止方位角(°)',
cancel: '取消',
confirm: '生成',
defaultLabel: '空域',
errPolygonPoints: '多边形至少需要3个有效顶点(经度,纬度)',
errRectNumbers: '请按(经度,纬度)格式填写有效的西南角与东北角',
errCircle: '请按(经度,纬度)填写有效的圆心与半径(千米)',
errSector: '请按(经度,纬度)填写有效的圆心、半径(千米)',
errBearing: '请填写有效的方位角',
needRoom: '请先进入任务房间',
successMsg: '空域已生成,将随房间自动保存',
errImport: '生成失败,请检查坐标与参数'
}
}

41
ruoyi-ui/src/store/modules/permission.js

@ -38,13 +38,16 @@ const permission = {
const rdata = JSON.parse(JSON.stringify(res.data))
const sidebarRoutes = filterAsyncRouter(sdata)
const rewriteRoutes = filterAsyncRouter(rdata, false, true)
// 二次排序:有图标的菜单优先展示;无图标的在下方。
// 说明:后端按 orderNum 排序,但若部分菜单 icon 为空会显得“乱套”。
const orderedSidebarRoutes = sortRoutesByIconPresence(sidebarRoutes)
const asyncRoutes = filterDynamicRoutes(dynamicRoutes)
rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true })
router.addRoutes(asyncRoutes)
commit('SET_ROUTES', rewriteRoutes)
commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(sidebarRoutes))
commit('SET_DEFAULT_ROUTES', sidebarRoutes)
commit('SET_TOPBAR_ROUTES', sidebarRoutes)
commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(orderedSidebarRoutes))
commit('SET_DEFAULT_ROUTES', orderedSidebarRoutes)
commit('SET_TOPBAR_ROUTES', orderedSidebarRoutes)
resolve(rewriteRoutes)
})
})
@ -103,6 +106,38 @@ function filterChildren(childrenMap, lastRouter = false) {
return children
}
/**
* 侧边栏排序优先展示带图标的菜单不带图标的排后
* 稳定排序同组内相对顺序保持不变
*/
function sortRoutesByIconPresence(routes) {
if (!Array.isArray(routes) || routes.length === 0) return routes
const withIcon = []
const withoutIcon = []
routes.forEach(r => {
// 图标字段来源:
// - 标准若依:meta.icon
// - 个别定制:直接放在 icon
const icon =
(r && r.meta && r.meta.icon) != null ? r.meta.icon
: (r && r.icon) != null ? r.icon
: ''
const iconStr = String(icon).trim()
// 约定:'#' 代表“未设置图标”,视为无图标
if (iconStr !== '' && iconStr !== '#') withIcon.push(r)
else withoutIcon.push(r)
})
const sorted = withIcon.concat(withoutIcon)
sorted.forEach(r => {
if (r && Array.isArray(r.children) && r.children.length > 0) {
r.children = sortRoutesByIconPresence(r.children)
}
})
return sorted
}
// 动态路由遍历,验证是否具备权限
export function filterDynamicRoutes(routes) {
const res = []

84
ruoyi-ui/src/views/cesiumMap/ContextMenu.vue

@ -22,7 +22,7 @@
</div>
</div>
<div class="menu-section" v-if="(!entityData || (entityData.type !== 'routePlatform' && entityData.type !== 'route')) && (!entityData || entityData.type !== 'platformBoxSelection')">
<div class="menu-section" v-if="(!entityData || (entityData.type !== 'routePlatform' && entityData.type !== 'route')) && (!entityData || entityData.type !== 'platformBoxSelection') && !isLockedAirspaceDrawing">
<div class="menu-item" @click="handleDelete">
<MenuGlyph name="trash" />
<span class="menu-label">删除</span>
@ -160,6 +160,15 @@
</div>
</div>
<!-- 空域多边形/矩形//扇形上锁后不可删除与修改属性 -->
<div class="menu-section" v-if="entityData && isAirspaceShapeType">
<div class="menu-title">空域</div>
<div class="menu-item" @click="handleToggleAirspaceLock">
<span class="menu-icon">{{ isAirspaceLocked ? '🔓' : '🔒' }}</span>
<span>{{ isAirspaceLocked ? '解锁' : '上锁' }}</span>
</div>
</div>
<!-- 航线上飞机显示/隐藏/编辑标牌编辑平台 -->
<div class="menu-section" v-if="entityData && entityData.type === 'routePlatform'">
<div class="menu-title">飞机标牌</div>
@ -247,21 +256,22 @@
<span>磁方位</span>
</div>
</div>
<template v-if="toolMode === 'ranging'">
<div class="menu-item" @click="toggleRangingUnitMenu">
<MenuGlyph name="ruler" />
<span class="menu-label">距离单位</span>
<span class="menu-value menu-value-text">{{ rangingDistanceUnit === 'nm' ? '海里' : '公里' }}</span>
<div class="menu-item" @click="toggleRangingUnitMenu">
<MenuGlyph name="ruler" />
<span class="menu-label">距离单位</span>
<span class="menu-value menu-value-text">{{ rangingDistanceUnitLabel }}</span>
</div>
<div class="sub-menu" v-if="showRangingUnitMenu">
<div class="sub-menu-item" @click="selectRangingUnit('m')" :class="{ active: rangingDistanceUnit === 'm' }">
<span>m</span>
</div>
<div class="sub-menu" v-if="showRangingUnitMenu">
<div class="sub-menu-item" @click="selectRangingUnit('km')" :class="{ active: rangingDistanceUnit === 'km' }">
<span>公里km</span>
</div>
<div class="sub-menu-item" @click="selectRangingUnit('nm')" :class="{ active: rangingDistanceUnit === 'nm' }">
<span>海里1 海里 = 1852 </span>
</div>
<div class="sub-menu-item" @click="selectRangingUnit('km')" :class="{ active: rangingDistanceUnit === 'km' }">
<span>公里km</span>
</div>
<div class="sub-menu-item" @click="selectRangingUnit('nm')" :class="{ active: rangingDistanceUnit === 'nm' }">
<span>海里NM</span>
</div>
</template>
</div>
</div>
<!-- 点特有选项 -->
@ -306,8 +316,8 @@
</div>
</div>
<!-- 空域图形调整位置 -->
<div class="menu-section" v-if="entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector' || entityData.type === 'auxiliaryLine' || entityData.type === 'arrow'">
<!-- 空域图形调整位置已上锁的多边形/矩形//扇形不可移动 -->
<div class="menu-section" v-if="entityData.type === 'auxiliaryLine' || entityData.type === 'arrow' || ((entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector') && !entityData.locked)">
<div class="menu-title">位置</div>
<div class="menu-item" @click="handleAdjustPosition">
<MenuGlyph name="anchor" />
@ -316,7 +326,7 @@
</div>
<!-- 空域图形命名多边形矩形圆形扇形 -->
<div class="menu-section" v-if="entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector'">
<div class="menu-section" v-if="(entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector') && !entityData.locked">
<div class="menu-title">命名</div>
<div class="menu-item" @click="handleEditAirspaceName">
<MenuGlyph name="editDoc" />
@ -326,7 +336,7 @@
</div>
<!-- 多边形特有选项 -->
<div class="menu-section" v-if="entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector' || entityData.type === 'powerZone'">
<div class="menu-section" v-if="((entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector') && !entityData.locked) || entityData.type === 'powerZone'">
<div class="menu-title">填充属性</div>
<div class="menu-item" @click="editName" v-if="entityData.type === 'powerZone'">
<MenuGlyph name="editDoc" />
@ -737,7 +747,7 @@ export default {
/** 根据视口边界修正菜单位置,避免菜单在屏幕底部或右侧被截断 */
adjustedPosition() {
const padding = 12
const menuMaxWidth = 264
const menuMaxWidth = 228
const menuMaxHeight = 640
const winW = typeof window !== 'undefined' ? window.innerWidth : 1920
const winH = typeof window !== 'undefined' ? window.innerHeight : 1080
@ -759,6 +769,17 @@ export default {
if (!this.entityData || this.entityData.routeId == null) return false
return !!this.routeLocked[this.entityData.routeId]
},
isAirspaceShapeType() {
const t = this.entityData && this.entityData.type
return ['polygon', 'rectangle', 'circle', 'sector'].includes(t)
},
isAirspaceLocked() {
return !!(this.entityData && this.entityData.locked)
},
/** 已上锁的空域:隐藏顶层「删除」 */
isLockedAirspaceDrawing() {
return this.isAirspaceShapeType && this.isAirspaceLocked
},
isHoldWaypoint() {
const ed = this.entityData || {}
if (!ed || ed.type !== 'routeWaypoint') return false
@ -766,6 +787,12 @@ export default {
const pt = ed.pointType || ed.point_type || ''
return pt === 'hold_circle' || pt === 'hold_ellipse'
},
rangingDistanceUnitLabel() {
const u = this.rangingDistanceUnit
if (u === 'nm') return '海里 NM'
if (u === 'km') return '公里 km'
return '米 m'
},
addWaypointDialogTitle() {
if (!this.addWaypointDialogMode) return '设置参数'
const dir = this.addWaypointDialogMode === 'before' ? '向前' : '向后'
@ -812,6 +839,10 @@ export default {
this.$emit('adjust-airspace-position')
},
handleToggleAirspaceLock() {
this.$emit('toggle-airspace-lock')
},
handleEditAirspaceName() {
const name = prompt('请输入图形名称:', this.entityData.name || '')
if (name !== null) {
@ -1197,7 +1228,7 @@ export default {
},
selectRangingUnit(unit) {
if (unit === 'km' || unit === 'nm') {
if (unit === 'm' || unit === 'km' || unit === 'nm') {
this.$emit('ranging-distance-unit', unit)
}
this.showRangingUnitMenu = false
@ -1215,8 +1246,10 @@ export default {
--ctx-surface: rgba(255, 255, 255, 0.72);
position: fixed;
z-index: 9999;
min-width: 188px;
max-width: 264px;
box-sizing: border-box;
width: 228px;
min-width: 228px;
max-width: 228px;
padding: 12px 0;
color: var(--ctx-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Microsoft YaHei', Arial, sans-serif;
@ -1535,14 +1568,19 @@ export default {
backdrop-filter: blur(10px);
border: 1px solid rgba(22, 93, 255, 0.1);
border-radius: 10px;
max-width: 100%;
box-sizing: border-box;
}
.sub-menu-item {
padding: 8px 16px 8px 28px;
padding: 8px 12px 8px 20px;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
font-size: 12px;
color: var(--ctx-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-menu-item:hover {

608
ruoyi-ui/src/views/cesiumMap/index.vue

@ -67,6 +67,7 @@
@edit-hold-speed="handleEditHoldSpeed"
@launch-missile="openLaunchMissileDialog"
@adjust-airspace-position="startAirspacePositionEdit"
@toggle-airspace-lock="toggleAirspaceLock"
@apply-whiteboard-platform-style="applyWhiteboardPlatformStyleFromMenu"
@apply-room-platform-icon-color="applyRoomPlatformIconColorFromMenu"
@close-menu="contextMenu.visible = false"
@ -619,7 +620,7 @@ export default {
},
/** 辅助线:水平/竖直约束,'none' | 'horizontal' | 'vertical' */
auxiliaryLineConstraint: 'none',
/** 测距模式距离显示:'km' 公里 | 'nm' 海里(1 海里 = 1852 m) */
/** 线段累计距离显示:'m' 米 | 'km' 公里 | 'nm' 海里(1 海里 = 1852 m) */
rangingDistanceUnit: 'km',
//
coordinatesText: '经度: --, 纬度: --',
@ -833,10 +834,21 @@ export default {
}
},
rangingDistanceUnit() {
if (this.toolMode !== 'ranging' || !this.allEntities) return
this.allEntities.forEach((ed) => {
if (ed && ed.type === 'line') this.updateLineSegmentLabels(ed)
})
const seen = new Set()
const refresh = (ed) => {
if (!ed || ed.type !== 'line' || ed.routeId != null) return
const id = ed.id
if (id != null && seen.has(String(id))) return
if (id != null) seen.add(String(id))
this.updateLineSegmentLabels(ed)
}
if (this.allEntities) this.allEntities.forEach(refresh)
if (this.whiteboardEntityDataMap) Object.values(this.whiteboardEntityDataMap).forEach(refresh)
// requestRenderMode postRender DOM
this.syncMapScreenDomLabels()
if (this.viewer && this.viewer.scene && this.viewer.scene.requestRenderMode) {
this.viewer.scene.requestRender()
}
}
},
computed: {
@ -2541,8 +2553,9 @@ export default {
}
},
//线
renderRouteWaypoints(waypoints, routeId = 'default', platformId, platform, style) {
if (!waypoints || waypoints.length < 1) return;
renderRouteWaypoints(waypointsRaw, routeId = 'default', platformId, platform, style) {
if (!waypointsRaw || waypointsRaw.length < 1) return;
const waypoints = this.sortWaypointsBySeq(waypointsRaw);
this.waypointDragPreview = null;
this.unregisterWaypointMapDomLabelsForRoute(routeId);
// 线线 + id 便
@ -2618,7 +2631,7 @@ export default {
this._routeWaypointsByRoute[routeId] = waypoints;
if (!this._routeHoldRadiiByRoute) this._routeHoldRadiiByRoute = {};
if (!this._routeHoldEllipseParamsByRoute) this._routeHoldEllipseParamsByRoute = {};
// i 线 45°
// i 线 45°线
const isTurnWaypointWithArc = (i) => {
if (i < 1 || i >= waypoints.length - 1) return false;
const wp = waypoints[i];
@ -2866,8 +2879,114 @@ export default {
}
// 线
if (waypoints.length > 1) {
const finalPathPositions = [originalPositions[0]];
let lastPos = originalPositions[0];
let finalPathPositions;
let lastPos;
if (this.isHoldWaypoint(waypoints[0])) {
finalPathPositions = [];
const wpFirstHold = waypoints[0];
const currPosFh = originalPositions[0];
const nextPosFh = originalPositions[1];
lastPos = this.getSyntheticInboundBeforeFirstHold(currPosFh, nextPosFh);
const paramsFh = this.parseHoldParams(wpFirstHold);
const legIndexHoldFh = 0;
const ptFh = wpFirstHold.pointType || wpFirstHold.point_type;
const useCircleFh = ptFh === 'hold_circle';
const effectiveTurnAngleFh = this.getEffectiveTurnAngle(wpFirstHold, 0, waypoints.length);
const prevWpForHoldFh = wpFirstHold;
const turnRadiusFh = this.getHoldRadiusFromPrevSpeed(wpFirstHold, prevWpForHoldFh, effectiveTurnAngleFh) || 500;
const radiusFh = turnRadiusFh;
const turnRadiusForHoldFh = turnRadiusFh;
const defaultSemiMajorFh = paramsFh && (paramsFh.semiMajor != null || paramsFh.semiMajorAxis != null) ? (paramsFh.semiMajor ?? paramsFh.semiMajorAxis) : 500;
const defaultSemiMinorFh = paramsFh && (paramsFh.semiMinor != null || paramsFh.semiMinorAxis != null) ? (paramsFh.semiMinor ?? paramsFh.semiMinorAxis) : 300;
const defaultHeadingRadFh = ((paramsFh && paramsFh.headingDeg != null ? paramsFh.headingDeg : 0) * Math.PI) / 180;
const edgeLengthMFh = Math.max(1000, paramsFh && paramsFh.edgeLength != null ? paramsFh.edgeLength : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M);
const arcRadiusMFh = turnRadiusFh;
const clockwiseFh = paramsFh && paramsFh.clockwise !== false;
const currPosClonedFh = Cesium.Cartesian3.clone(currPosFh);
const lastPosClonedFh = Cesium.Cartesian3.clone(lastPos);
const nextPosClonedFh = Cesium.Cartesian3.clone(nextPosFh);
const routeIdHoldFh = routeId;
const thatFh = this;
const buildHoldPositionsFh = (radiusOrEllipse, centerOverride) => {
const isCircleArg = typeof radiusOrEllipse === 'number';
const R = isCircleArg ? radiusOrEllipse : 0;
const smj = isCircleArg ? defaultSemiMajorFh : (radiusOrEllipse.semiMajor ?? defaultSemiMajorFh);
const smn = isCircleArg ? defaultSemiMinorFh : (radiusOrEllipse.semiMinor ?? defaultSemiMinorFh);
const hd = isCircleArg ? defaultHeadingRadFh : ((radiusOrEllipse.headingDeg != null ? radiusOrEllipse.headingDeg * Math.PI / 180 : defaultHeadingRadFh));
const centerPos = centerOverride || currPosClonedFh;
let entry; let exit; let centerForCircle;
if (useCircleFh) {
centerForCircle = thatFh.getHoldCenterFromPrevNext(lastPosClonedFh, centerPos, R, clockwiseFh);
entry = thatFh.getCircleTangentEntryPoint(centerForCircle, lastPosClonedFh, R, clockwiseFh);
exit = thatFh.getCircleTangentExitPoint(centerForCircle, nextPosClonedFh || centerPos, R, clockwiseFh);
} else {
entry = thatFh.getEllipseTangentEntryPoint(centerPos, lastPosClonedFh, smj, smn, hd, clockwiseFh);
exit = thatFh.getEllipseTangentExitPoint(centerPos, nextPosClonedFh || centerPos, smj, smn, hd, clockwiseFh);
}
let arcPoints;
if (useCircleFh) {
const center = centerForCircle;
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(center);
const eastVec = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3());
const northVec = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3());
const toEntry = Cesium.Cartesian3.subtract(entry, center, new Cesium.Cartesian3());
const startAngle = Math.atan2(Cesium.Cartesian3.dot(toEntry, eastVec), Cesium.Cartesian3.dot(toEntry, northVec));
const fullCirclePoints = thatFh.getCircleFullCircle(center, R, startAngle, clockwiseFh, 64);
const arcToExit = thatFh.getCircleArcEntryToExit(center, R, entry, exit, clockwiseFh, 32);
arcPoints = [entry, ...(fullCirclePoints || []).slice(1), ...(arcToExit || []).slice(1)];
if (!arcPoints || arcPoints.length < 2) arcPoints = [Cesium.Cartesian3.clone(entry), Cesium.Cartesian3.clone(exit)];
return arcPoints;
}
const tEntry = thatFh.cartesianToEllipseParam(centerPos, smj, smn, hd, entry);
const entryLocalAngle = Math.atan2(smn * Math.sin(tEntry), smj * Math.cos(tEntry));
const fullCirclePoints = thatFh.getEllipseFullCircle(centerPos, smj, smn, hd, entryLocalAngle, clockwiseFh, 128);
arcPoints = thatFh.buildEllipseHoldArc(centerPos, smj, smn, hd, entry, exit, clockwiseFh, 120);
return [entry, ...(fullCirclePoints || []).slice(1), ...(arcPoints || []).slice(1)];
};
const raceTrackDirectionRadFh = this.getRaceTrackDirectionRad(
currPosClonedFh,
lastPosClonedFh,
nextPosClonedFh,
paramsFh && paramsFh.headingDeg != null ? paramsFh.headingDeg : 0
);
const buildRaceTrackPositionsFh = (centerOverride) => thatFh.buildRaceTrackWithEntryExit(centerOverride || currPosClonedFh, lastPosClonedFh, nextPosClonedFh, raceTrackDirectionRadFh, edgeLengthMFh, arcRadiusMFh, clockwiseFh, 24);
const holdPositionsFh = useCircleFh ? buildHoldPositionsFh(radiusFh) : buildRaceTrackPositionsFh();
for (let k = 0; k < holdPositionsFh.length; k++) finalPathPositions.push(holdPositionsFh[k]);
const wpIdHoldFh = wpFirstHold.id;
const getHoldPositionsFh = () => {
let centerOverride = null;
if (thatFh.waypointDragging && thatFh.waypointDragging.routeId === routeIdHoldFh && thatFh.waypointDragging.dbId === wpIdHoldFh) {
const wpEnt = thatFh.viewer.entities.getById(`wp_${routeIdHoldFh}_${wpIdHoldFh}`);
if (wpEnt && wpEnt.position) {
const p = wpEnt.position.getValue(Cesium.JulianDate.now());
if (p) centerOverride = p;
}
}
if (useCircleFh) {
const R = (thatFh._routeHoldRadiiByRoute && thatFh._routeHoldRadiiByRoute[routeIdHoldFh] && thatFh._routeHoldRadiiByRoute[routeIdHoldFh][legIndexHoldFh] != null)
? thatFh._routeHoldRadiiByRoute[routeIdHoldFh][legIndexHoldFh]
: turnRadiusForHoldFh;
return buildHoldPositionsFh(R, centerOverride);
}
return buildRaceTrackPositionsFh(centerOverride);
};
this.viewer.entities.add({
id: `hold-line-${routeId}-0`,
show: false,
polyline: {
positions: new Cesium.CallbackProperty(getHoldPositionsFh, false),
width: lineWidth,
material: lineMaterial,
arcType: Cesium.ArcType.NONE,
zIndex: 20
},
properties: { routeId: routeId }
});
lastPos = holdPositionsFh[holdPositionsFh.length - 1];
} else {
finalPathPositions = [originalPositions[0]];
lastPos = originalPositions[0];
}
for (let i = 1; i < waypoints.length; i++) {
const currPos = originalPositions[i];
const wp = waypoints[i];
@ -3195,9 +3314,8 @@ export default {
return null;
},
/** 渲染/路径用:非首尾航点默认转弯坡度 45°,首尾为 0 */
/** 渲染/路径用:未设置转弯坡度时默认 45°(首尾可存盘并在盘旋等场景参与计算) */
getEffectiveTurnAngle(wp, index, waypointsLength) {
if (index === 0 || index === waypointsLength - 1) return wp.turnAngle != null ? wp.turnAngle : 0;
return wp.turnAngle != null ? wp.turnAngle : 45;
},
/** 转弯半径 R = v²/(g·tanθ),v 为速度(m/s),g=9.8,θ 为转弯坡度/坡度角(弧度) */
@ -3227,10 +3345,50 @@ export default {
return this.getHoldRadiusFromPrevSpeed(null, prevWp, turnAngle);
},
/**
* 首点为盘旋时没有上一航点 hold下一航点 的反方向取一参考点
* getHoldCenterFromPrevNext / 切点 几何与中间航点盘旋一致
*/
getSyntheticInboundBeforeFirstHold(holdCartesian, nextCartesian) {
const toNext = Cesium.Cartesian3.subtract(nextCartesian, holdCartesian, new Cesium.Cartesian3());
let dist = Cesium.Cartesian3.magnitude(toNext);
if (dist < 1e-3) {
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(holdCartesian);
const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3());
return Cesium.Cartesian3.add(
holdCartesian,
Cesium.Cartesian3.multiplyByScalar(east, -50000, new Cesium.Cartesian3()),
new Cesium.Cartesian3()
);
}
Cesium.Cartesian3.normalize(toNext, toNext);
const back = Math.max(dist, 5000);
return Cesium.Cartesian3.add(
holdCartesian,
Cesium.Cartesian3.multiplyByScalar(toNext, -back, new Cesium.Cartesian3()),
new Cesium.Cartesian3()
);
},
isHoldWaypoint(wp) {
const t = (wp && wp.pointType) || (wp && wp.point_type) || 'normal';
return t === 'hold_circle' || t === 'hold_ellipse';
},
/** 航线几何与平台起点均以 seq 最小航点为「出发航点」;与数据库 seq 一致,不依赖接口返回数组下标顺序 */
sortWaypointsBySeq(waypoints) {
if (!waypoints || !waypoints.length) return [];
return waypoints.slice().sort((a, b) => {
const saRaw = a.seq != null ? a.seq : a.Seq;
const sbRaw = b.seq != null ? b.seq : b.Seq;
const saNum = Number(saRaw);
const sbNum = Number(sbRaw);
// seq
const sa = Number.isFinite(saNum) ? saNum : Number.POSITIVE_INFINITY;
const sb = Number.isFinite(sbNum) ? sbNum : Number.POSITIVE_INFINITY;
if (sa !== sb) return sa - sb;
return (Number(a.id) || 0) - (Number(b.id) || 0);
});
},
parseHoldParams(wp) {
const raw = (wp && wp.holdParams) || (wp && wp.hold_params);
if (!raw) return null;
@ -4550,8 +4708,9 @@ export default {
* @param options - 可选 { holdRadiusByLegIndex: { [legIndex]: number } } 为指定盘旋段覆盖半径使落点精准在切点
* @returns {{ path, segmentEndIndices, holdArcRanges: { [legIndex]: { start, end } } }}
*/
getRoutePathWithSegmentIndices(waypoints, options) {
if (!waypoints || waypoints.length === 0) return { path: [], segmentEndIndices: [], holdArcRanges: {} };
getRoutePathWithSegmentIndices(waypointsRaw, options) {
if (!waypointsRaw || waypointsRaw.length === 0) return { path: [], segmentEndIndices: [], holdArcRanges: {} };
const waypoints = this.sortWaypointsBySeq(waypointsRaw);
const holdRadiusByLegIndex = (options && options.holdRadiusByLegIndex) || {};
const holdEllipseParamsByLegIndex = (options && options.holdEllipseParamsByLegIndex) || {};
const ellipsoid = this.viewer.scene.globe.ellipsoid;
@ -4566,11 +4725,69 @@ export default {
const originalPositions = waypoints.map(wp =>
Cesium.Cartesian3.fromDegrees(parseFloat(wp.lng), parseFloat(wp.lat), Number(wp.alt) || 0)
);
const path = [toLngLatAlt(originalPositions[0])];
const path = [];
const segmentEndIndices = [];
const holdArcRanges = {};
let lastPos = originalPositions[0];
let lastPos;
let prevWasHold = false;
if (waypoints.length > 1 && this.isHoldWaypoint(waypoints[0])) {
const wp = waypoints[0];
const currPos = originalPositions[0];
const nextPos = originalPositions[1];
lastPos = this.getSyntheticInboundBeforeFirstHold(currPos, nextPos);
const params = this.parseHoldParams(wp);
const legIndexFirst = 0;
const pt = wp.pointType || wp.point_type;
const useCircle = pt === 'hold_circle';
const clockwise = params && params.clockwise !== false;
const arcStartIdx = path.length;
let holdPositions;
if (useCircle) {
const prevWpForSpeed = wp;
const holdTurnAngle = this.getEffectiveTurnAngle(wp, 0, waypoints.length);
const turnRadius = this.getHoldRadiusFromPrevSpeed(wp, prevWpForSpeed, holdTurnAngle) || 500;
const radius = (holdRadiusByLegIndex[legIndexFirst] != null && Number.isFinite(holdRadiusByLegIndex[legIndexFirst]))
? holdRadiusByLegIndex[legIndexFirst]
: turnRadius;
const center = this.getHoldCenterFromPrevNext(lastPos, currPos, radius, clockwise);
const entry = this.getCircleTangentEntryPoint(center, lastPos, radius, clockwise);
const exit = this.getCircleTangentExitPoint(center, nextPos || currPos, radius, clockwise);
const enuPath = Cesium.Transforms.eastNorthUpToFixedFrame(center);
const eastPath = Cesium.Matrix4.getColumn(enuPath, 0, new Cesium.Cartesian3());
const northPath = Cesium.Matrix4.getColumn(enuPath, 1, new Cesium.Cartesian3());
const toEntryPath = Cesium.Cartesian3.subtract(entry, center, new Cesium.Cartesian3());
const startAnglePath = Math.atan2(Cesium.Cartesian3.dot(toEntryPath, eastPath), Cesium.Cartesian3.dot(toEntryPath, northPath));
const fullCirclePath = this.getCircleFullCircle(center, radius, startAnglePath, clockwise, 64);
const arcToExitPath = this.getCircleArcEntryToExit(center, radius, entry, exit, clockwise, 32);
holdPositions = [entry, ...(fullCirclePath || []).slice(1), ...(arcToExitPath || []).slice(1)];
if (!holdPositions || holdPositions.length < 2) holdPositions = [Cesium.Cartesian3.clone(entry), Cesium.Cartesian3.clone(exit)];
lastPos = exit;
} else {
const edgeLengthM = Math.max(1000, params && params.edgeLength != null ? params.edgeLength : this.DEFAULT_RACE_TRACK_EDGE_LENGTH_M);
const prevWpForSpeed = wp;
const holdTurnAngle = this.getEffectiveTurnAngle(wp, 0, waypoints.length);
const arcRadiusM = this.getHoldRadiusFromPrevSpeed(wp, prevWpForSpeed, holdTurnAngle) || 500;
const directionRad = this.getRaceTrackDirectionRad(
currPos,
lastPos,
nextPos,
params && params.headingDeg != null ? params.headingDeg : 0
);
holdPositions = this.buildRaceTrackWithEntryExit(currPos, lastPos, nextPos, directionRad, edgeLengthM, arcRadiusM, clockwise, 24);
lastPos = holdPositions.length ? holdPositions[holdPositions.length - 1] : currPos;
}
const holdLoopEndOffset = holdPositions._loopEndIndex != null ? holdPositions._loopEndIndex : null;
for (let k = 0; k < holdPositions.length; k++) path.push(toLngLatAlt(holdPositions[k]));
holdArcRanges[-1] = {
start: arcStartIdx,
end: path.length - 1,
loopEndIndex: holdLoopEndOffset != null ? arcStartIdx + holdLoopEndOffset : null
};
prevWasHold = true;
} else {
path.push(toLngLatAlt(originalPositions[0]));
lastPos = originalPositions[0];
}
for (let i = 1; i < waypoints.length; i++) {
const currPos = originalPositions[i];
const wp = waypoints[i];
@ -5372,7 +5589,7 @@ export default {
alt: wp.alt != null ? Number(wp.alt) : 5000,
speed: wp.speed != null ? wp.speed : 800,
startTime: wp.startTime || 'K+00:00:00',
turnAngle: wp.turnAngle != null ? wp.turnAngle : (idx === 0 || idx === this.copyPreviewWaypoints.length - 1 ? 0 : 45),
turnAngle: wp.turnAngle != null ? wp.turnAngle : 45,
labelFontSize: wp.labelFontSize != null ? wp.labelFontSize : 14,
labelColor: wp.labelColor || '#333333',
...(wp.pointType && { pointType: wp.pointType }),
@ -6963,26 +7180,13 @@ export default {
const length = this.calculateLineLength(tempPositions);
// bearingType
const bearing = this.calculateTrueBearing(tempPositions);
//
if (this.toolMode === 'ranging') {
this.hoverTooltip = {
visible: true,
content: `${this.formatRangingLengthText(length)}${bearing.toFixed(1)}°`,
position: {
x: movement.endPosition.x + 10,
y: movement.endPosition.y - 10
}
};
} else {
// 使
this.hoverTooltip = {
visible: true,
content: `长度:${length.toFixed(2)}\n真方位:${bearing.toFixed(2)}°`,
position: {
x: movement.endPosition.x + 10,
y: movement.endPosition.y - 10
}
};
this.hoverTooltip = {
visible: true,
content: `${this.formatRangingLengthText(length)}${bearing.toFixed(2)}°`,
position: {
x: movement.endPosition.x + 10,
y: movement.endPosition.y - 10
}
}
} else {
//
@ -7023,7 +7227,7 @@ export default {
kind: 'drawPoint',
entityId: pointId,
pixelOffset: { x: 15, y: 0 },
text: isStartPoint ? '起点' : `${(cumulativeDistance / 1000).toFixed(2)}km`
text: isStartPoint ? '起点' : this.formatRangingLengthText(cumulativeDistance)
}
}
this.drawingPointEntities.push(pointEntity);
@ -7106,7 +7310,20 @@ export default {
type: 'line',
label: '测距',
color: this.defaultStyles.line ? this.defaultStyles.line.color : '#165dff',
data: { points, width: (this.defaultStyles.line && this.defaultStyles.line.width) || 2 }
data: {
points,
width: (this.defaultStyles.line && this.defaultStyles.line.width) || 2,
bearingType: 'true'
}
}
// addLineEntity 线 entity线
this.unregisterDrawingPointDomLabelsForEntities(this.drawingPointEntities)
if (this.drawingPointEntities && this.drawingPointEntities.length) {
this.drawingPointEntities.forEach((pe) => {
try {
this.viewer.entities.remove(pe)
} catch (_) {}
})
}
this.$emit('whiteboard-draw-complete', entityData)
this.drawingPoints = []
@ -8693,17 +8910,21 @@ export default {
}
return totalLength
},
/** 测距:米 → 当前单位文案(方位角另拼);isTotal 时最后一段显示「共…」 */
formatRangingLengthText(meters, opts = {}) {
/** 线段距离:米 → 当前单位后缀 m / km / NM(与角度拼成「距离,角度°」) */
formatRangingLengthText(meters) {
const m = Number(meters)
if (!Number.isFinite(m)) return this.rangingDistanceUnit === 'nm' ? '0.00海里' : '0.0km'
const isTotal = opts.isTotal === true
if (!Number.isFinite(m)) {
if (this.rangingDistanceUnit === 'nm') return '0.00NM'
if (this.rangingDistanceUnit === 'km') return '0.00km'
return '0.00m'
}
if (this.rangingDistanceUnit === 'nm') {
const v = (m / 1852).toFixed(2)
return isTotal ? `${v}海里` : `${v}海里`
return `${(m / 1852).toFixed(2)}NM`
}
if (this.rangingDistanceUnit === 'km') {
return `${(m / 1000).toFixed(2)}km`
}
const v = (m / 1000).toFixed(1)
return isTotal ? `${v}km` : `${v}km`
return `${m.toFixed(2)}m`
},
/** 更新测距/空域线段每段终点的标签:屏幕 DOM,与 HoverTooltip 同类渲染 */
updateLineSegmentLabels(entityData) {
@ -8745,17 +8966,7 @@ export default {
const segLen = Cesium.Cartesian3.distance(positions[i], positions[i + 1])
cumulativeLength += segLen
const bearing = bearingFn([positions[i], positions[i + 1]])
const text =
this.toolMode === 'ranging'
? `${this.formatRangingLengthText(cumulativeLength)}${bearing.toFixed(1)}°`
: `累计长度:${cumulativeLength.toFixed(2)}\n${
bearingType === 'magnetic' ? '磁方位' : '真方位'
}${bearing.toFixed(2)}°`
const isLast = i === positions.length - 2
const displayText =
isLast && this.toolMode === 'ranging'
? `${this.formatRangingLengthText(cumulativeLength, { isTotal: true })} ,${bearing.toFixed(1)}°`
: text
const displayText = `${this.formatRangingLengthText(cumulativeLength)}${bearing.toFixed(2)}°`
const id = `${pref}${i}`
this._mapScreenDomLabelRegistry[id] = {
kind: 'segment',
@ -8763,7 +8974,7 @@ export default {
segmentVertexIndex: i + 1,
pixelOffset: { x: 15, y: 0 },
text: displayText,
multiline: displayText.indexOf('\n') >= 0
multiline: false
}
}
},
@ -9642,6 +9853,11 @@ export default {
this.contextMenu.visible = false;
return;
}
if (this.isAirspaceDrawingLocked(entityData)) {
this.contextMenu.visible = false;
this.$message && this.$message.warning('该空域已上锁,无法调整位置');
return;
}
this.contextMenu.visible = false;
entityData.entity.show = false;
const center = this.getAirspaceCenter(entityData);
@ -10101,6 +10317,11 @@ export default {
const ctx = this.airspacePositionEditContext;
if (!ctx || !ctx.entityData) return;
const entityData = ctx.entityData;
if (this.isAirspaceDrawingLocked(entityData)) {
this.clearAirspacePositionEdit();
this.$message && this.$message.warning('该空域已上锁,无法调整位置');
return;
}
// 穿/
if (this.airspacePositionEditPreviewEntity) {
this.viewer.entities.remove(this.airspacePositionEditPreviewEntity);
@ -10258,6 +10479,28 @@ export default {
this.$emit('route-lock-changed', { routeId, locked: nextLocked });
},
/** 右键空域:上锁/解锁;上锁后不可删除、不可改属性与调整位置 */
toggleAirspaceLock() {
const ed = this.contextMenu.entityData
if (!ed || !['polygon', 'rectangle', 'circle', 'sector'].includes(ed.type)) {
this.contextMenu.visible = false
return
}
const entityData = this.allEntities.find(e => e.id === ed.id && e.type === ed.type)
if (!entityData) {
this.contextMenu.visible = false
return
}
const nextLocked = !entityData.locked
this.$set(entityData, 'locked', nextLocked)
if (this.contextMenu.entityData && this.contextMenu.entityData.id === entityData.id) {
this.$set(this.contextMenu.entityData, 'locked', nextLocked)
}
this.contextMenu.visible = false
this.$message && this.$message.success(nextLocked ? '空域已上锁,不可删除与修改' : '空域已解锁')
this.notifyDrawingEntitiesChanged()
},
/** 右键飞机:切换该航线飞机标牌的显示/隐藏 */
toggleRouteLabelVisibility() {
const ed = this.contextMenu.entityData
@ -11745,14 +11988,36 @@ export default {
deleteEntityFromContextMenu() {
if (this.contextMenu.entityData) {
const entityData = this.contextMenu.entityData
if (this.isAirspaceDrawingLocked(entityData)) {
this.$message && this.$message.warning('该空域已上锁,无法删除')
this.contextMenu.visible = false
return
}
if (entityData.type === 'platformBoxSelection') {
this.contextMenu.visible = false
return
}
if (entityData.isWhiteboard) {
this.$emit('whiteboard-entity-deleted', entityData)
if (entityData.type === 'line') {
this.unregisterLineSegmentDomLabels(entityData.id)
if (entityData.pointEntities && entityData.pointEntities.length) {
entityData.pointEntities.forEach((pe) => {
try {
this.viewer.entities.remove(pe)
} catch (_) {}
})
}
}
if (entityData.type === 'text' && entityData.entity && entityData.entity.id) {
this.unregisterMapTextDomLabel(`map-dom-maptext-text-${entityData.entity.id}`)
}
if (entityData.entity) this.viewer.entities.remove(entityData.entity)
if (this.whiteboardEntityDataMap && entityData.id) delete this.whiteboardEntityDataMap[entityData.id]
this.syncMapScreenDomLabels()
if (this.viewer.scene && this.viewer.scene.requestRenderMode) {
this.viewer.scene.requestRender()
}
} else if (entityData.type === 'detectionZone' || entityData.type === 'powerZone') {
const type = entityData.type
const zoneId = entityData.zoneId
@ -12115,6 +12380,11 @@ export default {
updateEntityProperty(property, value) {
if (this.contextMenu.entityData) {
const entityData = this.contextMenu.entityData
if (this.isAirspaceDrawingLocked(entityData)) {
this.$message && this.$message.warning('该空域已上锁,无法修改')
this.contextMenu.visible = false
return
}
//
entityData[property] = value
// 20%
@ -12133,6 +12403,13 @@ export default {
}
//
this.updateEntityStyle(entityData)
if (property === 'bearingType' && entityData.type === 'line' && entityData.routeId == null) {
this.updateLineSegmentLabels(entityData)
this.syncMapScreenDomLabels()
if (this.viewer && this.viewer.scene && this.viewer.scene.requestRenderMode) {
this.viewer.scene.requestRender()
}
}
// contentByTime
if (entityData.isWhiteboard && this.getDrawingEntityTypes().includes(entityData.type)) {
this.$emit('whiteboard-drawing-updated', this.serializeWhiteboardDrawingEntity(entityData))
@ -12334,6 +12611,10 @@ export default {
)
if (index > -1) {
const entity = this.allEntities[index]
if (this.isAirspaceDrawingLocked(entity)) {
this.$message && this.$message.warning('该空域已上锁,无法删除')
return
}
// / Redis
if (entity.type === 'point' && entity.id) {
this.ensureMapScreenDomLabelRegistry()
@ -12431,14 +12712,16 @@ export default {
// /线
if (this.toolMode === 'airspace') {
if (showConfirm) {
this.$confirm('是否清除所有空域图形?(平台与航线将保留)', '提示', {
this.$confirm('是否清除所有空域图形?(平台与航线将保留;已上锁空域将保留)', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const lockedN = (this.allEntities || []).filter(e => this.isAirspaceDrawingLocked(e)).length
this.clearDrawingEntities();
this.notifyDrawingEntitiesChanged();
this.$message({ type: 'success', message: '已清除空域图形' });
const msg = lockedN > 0 ? `已清除未上锁图形(已保留 ${lockedN} 个已上锁空域)` : '已清除空域图形'
this.$message({ type: 'success', message: msg });
}).catch(() => {});
} else {
this.clearDrawingEntities();
@ -12574,6 +12857,13 @@ export default {
getDrawingEntityTypes() {
return ['line', 'polygon', 'rectangle', 'circle', 'sector', 'auxiliaryLine', 'arrow', 'text', 'image', 'powerZone']
},
/** 多边形/矩形/圆/扇形空域是否已上锁 */
isAirspaceDrawingLocked(entity) {
if (!entity) return false
const t = entity.type
if (!['polygon', 'rectangle', 'circle', 'sector'].includes(t)) return false
return !!entity.locked
},
/** 空域/威力区图形增删时通知父组件,用于自动保存到房间(从房间加载时不触发) */
notifyDrawingEntitiesChanged() {
if (this.loadingDrawingsFromRoom) return
@ -12626,6 +12916,9 @@ export default {
data.points = entityData.points
data.width = entityData.width != null ? entityData.width : 2
data.color = entityData.color || entityData.data?.color
if (entityData.type === 'line') {
data.bearingType = entityData.bearingType || entityData.data?.bearingType || 'true'
}
}
break
case 'arrow':
@ -12659,7 +12952,8 @@ export default {
opacity: entity.opacity != null ? entity.opacity : 0,
width: entity.width != null ? entity.width : 2,
borderColor: entity.borderColor || entity.color,
name: entity.name || ''
name: entity.name || '',
locked: !!entity.locked
}; break
case 'rectangle':
if (entity.points && entity.points.length >= 2) {
@ -12671,7 +12965,8 @@ export default {
opacity: entity.opacity != null ? entity.opacity : 0,
width: entity.width != null ? entity.width : 2,
borderColor: entity.borderColor || entity.color,
name: entity.name || ''
name: entity.name || '',
locked: !!entity.locked
}
} else {
data = {
@ -12680,7 +12975,8 @@ export default {
opacity: entity.opacity != null ? entity.opacity : 0,
width: entity.width != null ? entity.width : 2,
borderColor: entity.borderColor || entity.color,
name: entity.name || ''
name: entity.name || '',
locked: !!entity.locked
}
}
break
@ -12692,7 +12988,8 @@ export default {
opacity: entity.opacity != null ? entity.opacity : 0,
width: entity.width != null ? entity.width : 2,
borderColor: entity.borderColor || entity.color,
name: entity.name || ''
name: entity.name || '',
locked: !!entity.locked
}; break
case 'ellipse':
case 'hold_ellipse':
@ -12711,7 +13008,8 @@ export default {
opacity: entity.opacity != null ? entity.opacity : 0,
width: entity.width != null ? entity.width : 2,
borderColor: entity.borderColor || entity.color,
name: entity.name || ''
name: entity.name || '',
locked: !!entity.locked
}; break
case 'auxiliaryLine':
data = {
@ -12802,7 +13100,7 @@ export default {
clearDrawingEntities() {
if (!this.allEntities || !this.viewer) return
const types = this.getDrawingEntityTypes()
const toRemove = this.allEntities.filter(item => types.includes(item.type))
const toRemove = this.allEntities.filter(item => types.includes(item.type) && !this.isAirspaceDrawingLocked(item))
toRemove.forEach(item => {
try {
if (item.type === 'text' && item.entity && item.entity.id) {
@ -12825,7 +13123,7 @@ export default {
}
} catch (e) { console.warn('clearDrawingEntities:', e) }
})
this.allEntities = this.allEntities.filter(item => !types.includes(item.type))
this.allEntities = this.allEntities.filter(item => !types.includes(item.type) || this.isAirspaceDrawingLocked(item))
},
/** 从屏幕坐标获取经纬度(用于白板拖放等) */
getLatLngFromScreen(clientX, clientY) {
@ -12854,7 +13152,30 @@ export default {
this.whiteboardHiddenEntityShows[id] = origShow
entity.show = false
})
// /线 DOM Cesium entity.show
if (this.allEntities && this.allEntities.length) {
this.allEntities.forEach((ed) => {
if (!ed || ed.type !== 'line' || ed.routeId != null || ed.isWhiteboard) return
const lid = ed.id
if (lid != null && !String(lid).startsWith('wb_')) {
this.unregisterLineSegmentDomLabels(lid)
}
})
}
this.ensureMapScreenDomLabelRegistry()
if (this._mapScreenDomLabelRegistry) {
Object.keys(this._mapScreenDomLabelRegistry).forEach((k) => {
if (k.startsWith('map-dom-drawlpt-')) delete this._mapScreenDomLabelRegistry[k]
})
}
this.hoverTooltip = this.hoverTooltip || {}
this.hoverTooltip.visible = false
this.measurementResult = null
this.renderWhiteboardEntities(this.whiteboardEntities || [])
this.syncMapScreenDomLabels()
if (this.viewer.scene && this.viewer.scene.requestRenderMode) {
this.viewer.scene.requestRender()
}
} else {
Object.keys(this.whiteboardHiddenEntityShows || {}).forEach(id => {
const entity = this.viewer.entities.getById(id)
@ -12862,6 +13183,20 @@ export default {
})
this.whiteboardHiddenEntityShows = {}
this.clearWhiteboardEntities()
// 线
if (this.allEntities && this.allEntities.length) {
this.allEntities.forEach((ed) => {
if (!ed || ed.type !== 'line' || ed.routeId != null || ed.isWhiteboard) return
const lid = ed.id
if (lid != null && !String(lid).startsWith('wb_')) {
this.updateLineSegmentLabels(ed)
}
})
}
this.syncMapScreenDomLabels()
if (this.viewer.scene && this.viewer.scene.requestRenderMode) {
this.viewer.scene.requestRender()
}
}
},
/** 清除白板实体(id 以 wb_ 开头) */
@ -12892,7 +13227,15 @@ export default {
if (id) wantIds.add(id)
} else {
const id = (ed.id || '').startsWith('wb_') ? ed.id : ('wb_' + (ed.id || ''))
if (id) wantIds.add(id)
if (id) {
wantIds.add(id)
// 线 entity id `${lineId}_vtx_${i}` wantIds
if (ed.type === 'line' && ed.data && Array.isArray(ed.data.points)) {
for (let vi = 0; vi < ed.data.points.length; vi++) {
wantIds.add(`${id}_vtx_${vi}`)
}
}
}
}
})
@ -12908,6 +13251,16 @@ export default {
if (ed && ed.type === 'text' && ed.entity && ed.entity.id) {
this.unregisterMapTextDomLabel(`map-dom-maptext-text-${ed.entity.id}`)
}
if (ed && ed.type === 'line') {
this.unregisterLineSegmentDomLabels(ed.id)
if (ed.pointEntities && ed.pointEntities.length) {
ed.pointEntities.forEach((pe) => {
try {
this.viewer.entities.remove(pe)
} catch (_) {}
})
}
}
if (ed && ed.entity) this.viewer.entities.remove(ed.entity)
delete this.whiteboardEntityDataMap[id]
}
@ -13027,6 +13380,16 @@ export default {
if (existing.type === 'text' && existing.entity.id) {
this.unregisterMapTextDomLabel(`map-dom-maptext-text-${existing.entity.id}`)
}
if (existing.type === 'line') {
this.unregisterLineSegmentDomLabels(existing.id)
if (existing.pointEntities && existing.pointEntities.length) {
existing.pointEntities.forEach((pe) => {
try {
this.viewer.entities.remove(pe)
} catch (_) {}
})
}
}
this.viewer.entities.remove(existing.entity)
delete this.whiteboardEntityDataMap[id]
}
@ -13198,7 +13561,51 @@ export default {
entityData.borderColor = sectorBorderColor
break
}
case 'line':
case 'line': {
const pts = (data.points || []).map(p => Cesium.Cartesian3.fromDegrees(p.lng, p.lat))
if (pts.length < 2) return
const lineWidth = data.width != null ? data.width : 2
const lineColor = data.color || color
const lineId = entityData.id
const pointEntities = []
for (let i = 0; i < pts.length; i++) {
const ptId = `${lineId}_vtx_${i}`
const pe = this.viewer.entities.add({
id: ptId,
position: pts[i],
point: {
pixelSize: this.defaultStyles.point.size,
color: Cesium.Color.fromCssColorString(this.defaultStyles.point.color),
outlineColor: Cesium.Color.WHITE,
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY
}
})
pointEntities.push(pe)
}
const positionsProp = new Cesium.CallbackProperty(() => {
return pointEntities.map((pEnt) => {
const pos = pEnt.position.getValue(Cesium.JulianDate.now())
return pos || Cesium.Cartesian3.ZERO
})
}, false)
entity = this.viewer.entities.add({
id: lineId,
polyline: {
positions: positionsProp,
width: lineWidth,
material: Cesium.Color.fromCssColorString(lineColor),
arcType: Cesium.ArcType.NONE
}
})
entityData.positions = pts
entityData.points = data.points || pts.map(p => this.cartesianToLatLng(p))
entityData.width = lineWidth
entityData.color = lineColor
entityData.pointEntities = pointEntities
entityData.bearingType = data.bearingType || 'true'
break
}
case 'auxiliaryLine': {
const pts = (data.points || []).map(p => Cesium.Cartesian3.fromDegrees(p.lng, p.lat))
if (pts.length < 2) return
@ -13269,6 +13676,9 @@ export default {
if (entityData.type === 'text') {
this.registerMapTextForInsertedText(entityData)
}
if (entityData.type === 'line') {
this.updateLineSegmentLabels(entityData)
}
}
},
/** 从房间/方案加载的 frontend_drawings JSON 恢复空域图形(先清空当前图形再导入;加载期间不触发自动保存) */
@ -13485,7 +13895,8 @@ export default {
opacity: polyOpacity,
width: polyWidth,
label: entityData.label || '面',
name: entityData.data.name || ''
name: entityData.data.name || '',
locked: entityData.data.locked === true
}
this.allEntities.push(polyEntityData)
if (polyEntityData.name) this.updateAirspaceEntityLabel(polyEntityData)
@ -13532,10 +13943,12 @@ export default {
opacity: rectOpacity,
width: rectWidth,
label: entityData.label || '矩形',
name: entityData.data.name || ''
name: entityData.data.name || '',
locked: entityData.data.locked === true
}
this.allEntities.push(rectEntityData)
if (rectEntityData.name) this.updateAirspaceEntityLabel(rectEntityData)
this.notifyDrawingEntitiesChanged()
return
}
case 'circle': {
@ -13576,7 +13989,8 @@ export default {
opacity: circleOpacity,
width: circleWidth,
label: entityData.label || '圆形',
name: entityData.data.name || ''
name: entityData.data.name || '',
locked: entityData.data.locked === true
}
this.allEntities.push(circleEntityData)
if (circleEntityData.name) this.updateAirspaceEntityLabel(circleEntityData)
@ -13683,7 +14097,8 @@ export default {
opacity: sectorOpacity,
width: sectorWidth,
label: entityData.label || '扇形',
name: entityData.data.name || ''
name: entityData.data.name || '',
locked: d.locked === true
}
this.allEntities.push(sectorEntityData)
if (sectorEntityData.name) this.updateAirspaceEntityLabel(sectorEntityData)
@ -14514,18 +14929,23 @@ export default {
'0 0 2px #fff, 0 0 2px #fff, 0 0 2px #fff, 0 1px 2px rgba(0,0,0,0.35)'
}
} else if (r.kind === 'segment') {
const positions = this.getLineEntityPositionsForDomLabels(
this.allEntities.find((e) => e.id === r.lineId && e.type === 'line'),
time
)
world =
positions && positions.length > r.segmentVertexIndex
? positions[r.segmentVertexIndex]
: undefined
text = r.text || ''
multiline = !!r.multiline
transform = 'translate(0, -50%)'
themeClass = 'map-screen-dom-label--tooltip'
// 线 DOM
if (this.whiteboardMode && r.lineId != null && !String(r.lineId).startsWith('wb_')) {
visible = false
} else {
const lineEd =
(this.allEntities && this.allEntities.find((e) => e.id === r.lineId && e.type === 'line')) ||
(this.whiteboardEntityDataMap && this.whiteboardEntityDataMap[r.lineId])
const positions = this.getLineEntityPositionsForDomLabels(lineEd, time)
world =
positions && positions.length > r.segmentVertexIndex
? positions[r.segmentVertexIndex]
: undefined
text = r.text || ''
multiline = !!r.multiline
transform = 'translate(0, -50%)'
themeClass = 'map-screen-dom-label--tooltip'
}
} else if (r.kind === 'drawPoint') {
const e = this.viewer.entities.getById(r.entityId)
world = e && e.position && e.position.getValue(time)
@ -15016,10 +15436,11 @@ export default {
text-align: right;
}
/* 优化:居中且带圆角 */
/* 与「编辑航线」面板一致:8px 圆角、白底标题栏、标题字重与内边距 */
::v-deep .el-dialog {
border-radius: 12px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
margin: 0 !important;
@ -15030,23 +15451,24 @@ export default {
}
::v-deep .el-dialog .el-dialog__header {
background-color: #f5f7fa;
background-color: #fff;
border-bottom: 1px solid #ebeef5;
padding: 15px 20px;
padding: 14px 20px 12px;
}
::v-deep .el-dialog .el-dialog__title {
font-weight: 600;
font-weight: 700;
font-size: 16px;
color: #303133;
}
::v-deep .el-dialog .el-dialog__body {
padding: 20px 25px;
padding: 12px 20px 16px;
}
::v-deep .el-dialog .el-dialog__footer {
border-top: 1px solid #ebeef5;
padding: 10px 20px;
padding: 10px 20px 16px;
background-color: #fff;
}

33
ruoyi-ui/src/views/childRoom/GanttDrawer.vue

@ -741,8 +741,8 @@ export default {
flex-direction: column;
background: #fff;
border-radius: 8px;
border: 1px solid #e4e7ed;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12);
border: none;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 2500;
overflow: hidden;
}
@ -1078,17 +1078,16 @@ export default {
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px 8px 16px;
border-bottom: 1px solid #e9edf3;
background: linear-gradient(180deg, #fafbfd 0%, #f5f8fc 100%);
padding: 14px 20px 12px;
border-bottom: 1px solid #ebeef5;
background: #fff;
cursor: move;
}
.gantt-dialog-title-text {
font-size: 14px;
line-height: 20px;
font-weight: 500;
color: #5c6778;
letter-spacing: 0.2px;
font-size: 16px;
line-height: 1.4;
font-weight: 700;
color: #303133;
}
.gantt-dialog-title-actions {
display: inline-flex;
@ -1096,23 +1095,23 @@ export default {
gap: 2px;
}
.gantt-dialog-header-icon {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-size: 16px;
color: #909399;
cursor: pointer;
transition: color 0.2s;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
border-radius: 50%;
border: none;
background: none;
outline: none;
}
.gantt-dialog-header-icon:hover {
color: #303133;
background: #eef2f8;
color: #606266;
background: #f5f5f5;
}
.fade-enter-active,
.fade-leave-active {

37
ruoyi-ui/src/views/childRoom/SixStepsOverlay.vue

@ -113,8 +113,18 @@
</el-dropdown-menu>
</el-dropdown>
</template>
</div>
<div class="header-right">
<button
type="button"
class="overlay-exit-btn"
title="退出六步法"
@click="close"
>
退出
</button>
</div>
</div>
</div>
<div class="overlay-body" :style="overlayBodyStyle">
<div class="overlay-content" :class="{
'task-page': overrideTitle === '任务',
@ -876,6 +886,31 @@ export default {
align-items: center;
gap: 16px;
margin-left: 2px;
min-width: 0;
flex: 1;
}
.header-right {
flex-shrink: 0;
margin-left: 12px;
}
.overlay-exit-btn {
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
color: #64748b;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 8px;
cursor: pointer;
transition: color 0.2s, background 0.2s, border-color 0.2s;
}
.overlay-exit-btn:hover {
color: #165dff;
background: #fff;
border-color: rgba(22, 93, 255, 0.35);
}
.header-insert {

5
ruoyi-ui/src/views/childRoom/TopHeader.vue

@ -154,6 +154,7 @@
<el-dropdown-menu slot="dropdown" class="file-dropdown-menu">
<el-dropdown-item @click.native="powerZone">{{ $t('topHeader.airspace.powerZone') }}</el-dropdown-item>
<el-dropdown-item @click.native="threatZone">{{ $t('topHeader.airspace.threatZone') }}</el-dropdown-item>
<el-dropdown-item @click.native="generateAirspace">{{ $t('topHeader.airspace.generateAirspace') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
@ -628,6 +629,10 @@ export default {
this.$emit('threat-zone')
},
generateAirspace() {
this.$emit('generate-airspace')
},
//
routeCalculation() {
this.$emit('route-calculation')

732
ruoyi-ui/src/views/childRoom/index.vue

File diff suppressed because it is too large

402
ruoyi-ui/src/views/dialogs/GenerateAirspaceDialog.vue

@ -0,0 +1,402 @@
<template>
<el-dialog
:title="$t('generateAirspace.title')"
:visible.sync="visible"
width="540px"
append-to-body
:close-on-click-modal="false"
@closed="onClosed"
>
<el-form ref="formRef" :model="form" label-width="150px" size="small">
<el-form-item :label="$t('generateAirspace.shapeType')">
<el-select v-model="form.shapeType" style="width: 100%;">
<el-option :label="$t('generateAirspace.polygon')" value="polygon" />
<el-option :label="$t('generateAirspace.rectangle')" value="rectangle" />
<el-option :label="$t('generateAirspace.circle')" value="circle" />
<el-option :label="$t('generateAirspace.sector')" value="sector" />
</el-select>
</el-form-item>
<el-form-item :label="$t('generateAirspace.name')">
<el-input v-model="form.name" :placeholder="$t('generateAirspace.namePlaceholder')" clearable />
</el-form-item>
<el-form-item :label="$t('generateAirspace.color')">
<el-color-picker v-model="form.color" show-alpha />
</el-form-item>
<el-form-item :label="$t('generateAirspace.borderWidth')">
<el-input-number v-model="form.width" :min="1" :max="20" :step="1" controls-position="right" style="width: 100%;" />
</el-form-item>
<template v-if="form.shapeType === 'polygon'">
<el-form-item :label="$t('generateAirspace.vertices')" required>
<el-input
v-model="form.polygonText"
type="textarea"
:rows="6"
:placeholder="$t('generateAirspace.polygonPlaceholder')"
/>
</el-form-item>
</template>
<template v-else-if="form.shapeType === 'rectangle'">
<el-form-item :label="$t('generateAirspace.rectangleSwCorner')" required>
<el-input v-model="form.swCorner" :placeholder="$t('generateAirspace.cornerLonLatPlaceholder')" clearable />
</el-form-item>
<el-form-item :label="$t('generateAirspace.rectangleNeCorner')" required>
<el-input v-model="form.neCorner" :placeholder="$t('generateAirspace.cornerLonLatPlaceholder')" clearable />
</el-form-item>
</template>
<template v-else-if="form.shapeType === 'circle'">
<el-form-item :label="$t('generateAirspace.centerLonLat')" required>
<el-input v-model="form.centerCorner" :placeholder="$t('generateAirspace.cornerLonLatPlaceholder')" clearable />
</el-form-item>
<el-form-item :label="$t('generateAirspace.radiusM')" required>
<div class="input-with-unit">
<el-input-number
v-model="form.radius"
:min="0.1"
:max="2000"
:step="1"
:precision="2"
controls-position="right"
class="input-with-unit__num"
/>
<span class="input-with-unit__suffix">{{ $t('generateAirspace.radiusUnit') }}</span>
</div>
</el-form-item>
</template>
<template v-else-if="form.shapeType === 'sector'">
<el-form-item :label="$t('generateAirspace.centerLonLat')" required>
<el-input v-model="form.centerCorner" :placeholder="$t('generateAirspace.cornerLonLatPlaceholder')" clearable />
</el-form-item>
<el-form-item :label="$t('generateAirspace.radiusM')" required>
<div class="input-with-unit">
<el-input-number
v-model="form.radius"
:min="0.1"
:max="2000"
:step="1"
:precision="2"
controls-position="right"
class="input-with-unit__num"
/>
<span class="input-with-unit__suffix">{{ $t('generateAirspace.radiusUnit') }}</span>
</div>
</el-form-item>
<el-form-item :label="$t('generateAirspace.startBearing')" required>
<el-input-number v-model="form.startBearing" :min="0" :max="360" :precision="2" :step="1" controls-position="right" style="width: 100%;" />
</el-form-item>
<el-form-item :label="$t('generateAirspace.endBearing')" required>
<el-input-number v-model="form.endBearing" :min="0" :max="360" :precision="2" :step="1" controls-position="right" style="width: 100%;" />
</el-form-item>
</template>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="visible = false">{{ $t('generateAirspace.cancel') }}</el-button>
<el-button type="primary" @click="submit">{{ $t('generateAirspace.confirm') }}</el-button>
</span>
</el-dialog>
</template>
<script>
function parseNum(v) {
if (v === null || v === undefined || v === '') return NaN
const n = typeof v === 'number' ? v : parseFloat(String(v).trim().replace(/,/g, ''))
return n
}
/** 从颜色选择器返回值解析填充不透明度(与 Cesium importEntity 的 data.opacity 一致) */
function extractOpacityFromColor(colorStr) {
if (colorStr == null || colorStr === '') return 0
const s = String(colorStr).trim()
const rgba = s.match(
/^rgba\s*\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)/i
)
if (rgba) return Math.max(0, Math.min(1, parseFloat(rgba[1])))
if (/^rgb\s*\(/i.test(s) && !/^rgba/i.test(s)) return 1
const hsla = s.match(
/^hsla\s*\(\s*[\d.]+\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*,\s*([\d.]+)\s*\)/i
)
if (hsla) return Math.max(0, Math.min(1, parseFloat(hsla[1])))
if (s[0] === '#' && s.length === 9) {
const a = parseInt(s.slice(7, 9), 16) / 255
return Number.isFinite(a) ? Math.max(0, Math.min(1, a)) : 0
}
if (s[0] === '#') return 0
return 0
}
/** 解析单个角点:(经度,纬度) 或(经度,纬度),也支持无括号的 经度,纬度 */
function parseLonLatPair(text) {
const raw = String(text == null ? '' : text).trim()
if (!raw) return null
const parenPair =
/[((]\s*([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*[,,]\s*([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*[))]/
const m = raw.match(parenPair)
if (m) {
const lng = parseFloat(m[1])
const lat = parseFloat(m[2])
if (Number.isFinite(lng) && Number.isFinite(lat)) return { lng, lat }
}
const cleaned = raw.replace(/^[((]\s*|\s*[))]$/g, '').trim()
const parts = cleaned.split(/[,,\s]+/).map(s => s.trim()).filter(Boolean)
if (parts.length >= 2) {
const lng = parseNum(parts[0])
const lat = parseNum(parts[1])
if (Number.isFinite(lng) && Number.isFinite(lat)) return { lng, lat }
}
return null
}
export default {
name: 'GenerateAirspaceDialog',
props: {
value: {
type: Boolean,
default: false
}
},
data() {
return {
form: {
shapeType: 'polygon',
name: '',
color: 'rgba(0, 138, 255, 0)',
width: 2,
polygonText: '',
swCorner: '',
neCorner: '',
centerCorner: '',
radius: 50,
startBearing: 0,
endBearing: 90
}
}
},
computed: {
visible: {
get() {
return this.value
},
set(v) {
this.$emit('input', v)
}
}
},
methods: {
onClosed() {
this.$emit('closed')
},
resetForm() {
this.form = {
shapeType: 'polygon',
name: '',
color: 'rgba(0, 138, 255, 0)',
width: 2,
polygonText: '',
swCorner: '',
neCorner: '',
centerCorner: '',
radius: 50,
startBearing: 0,
endBearing: 90
}
if (this.$refs.formRef) this.$refs.formRef.clearValidate()
},
/**
* 支持多种写法
* - 每行一对经度,纬度可中英文逗号空格
* - 一行多对用顿号/分号分隔 121.47,31.23120.15,30.28
* - 带括号121.47, 31.23(120.15, 30.28)全角/半角括号与逗号均可
*/
parsePolygonPoints(text) {
const raw = String(text || '').trim()
if (!raw) return []
const points = []
const parenPair =
/[((]\s*([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*[,,]\s*([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*[))]/g
let m
while ((m = parenPair.exec(raw)) !== null) {
const lng = parseFloat(m[1])
const lat = parseFloat(m[2])
if (Number.isFinite(lng) && Number.isFinite(lat)) points.push({ lng, lat })
}
if (points.length > 0) return points
const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
for (const line of lines) {
const segments = line.split(/[、;;]+/).map(s => s.trim()).filter(Boolean)
const segs = segments.length > 0 ? segments : [line]
for (const seg of segs) {
const cleaned = seg.replace(/^[((]\s*|\s*[))]$/g, '').trim()
const parts = cleaned.split(/[,,\s]+/).map(s => s.trim()).filter(Boolean)
if (parts.length < 2) continue
const lng = parseNum(parts[0])
const lat = parseNum(parts[1])
if (Number.isFinite(lng) && Number.isFinite(lat)) points.push({ lng, lat })
}
}
return points
},
/** 方位角:正北为 0°、顺时针增大,转为 importEntity 扇形所用的数学角(与地图绘制一致) */
bearingDegToSectorAngle(bearingDeg) {
const b = (bearingDeg % 360 + 360) % 360
return Math.PI / 2 - (b * Math.PI) / 180
},
buildPayload() {
const color = this.form.color || 'rgba(0, 138, 255, 0)'
const opacity = extractOpacityFromColor(color)
const width = this.form.width != null ? this.form.width : 2
const borderColor = color
const name = (this.form.name || '').trim()
const labelBase = {
polygon: this.$t('generateAirspace.polygon'),
rectangle: this.$t('generateAirspace.rectangle'),
circle: this.$t('generateAirspace.circle'),
sector: this.$t('generateAirspace.sector')
}
const label = name || labelBase[this.form.shapeType] || this.$t('generateAirspace.defaultLabel')
switch (this.form.shapeType) {
case 'polygon': {
const points = this.parsePolygonPoints(this.form.polygonText)
if (points.length < 3) {
this.$message.error(this.$t('generateAirspace.errPolygonPoints'))
return null
}
return {
type: 'polygon',
label,
color,
data: {
points,
opacity,
width,
borderColor,
name
}
}
}
case 'rectangle': {
const sw = parseLonLatPair(this.form.swCorner)
const ne = parseLonLatPair(this.form.neCorner)
if (!sw || !ne) {
this.$message.error(this.$t('generateAirspace.errRectNumbers'))
return null
}
const w = Math.min(sw.lng, ne.lng)
const e = Math.max(sw.lng, ne.lng)
const s = Math.min(sw.lat, ne.lat)
const n = Math.max(sw.lat, ne.lat)
return {
type: 'rectangle',
label,
color,
data: {
coordinates: { west: w, south: s, east: e, north: n },
points: [
{ lng: w, lat: s },
{ lng: e, lat: n }
],
opacity,
width,
borderColor,
name
}
}
}
case 'circle': {
const c = parseLonLatPair(this.form.centerCorner)
const radiusKm = Number(this.form.radius)
const radius = radiusKm * 1000
if (!c || !Number.isFinite(radiusKm) || radiusKm <= 0) {
this.$message.error(this.$t('generateAirspace.errCircle'))
return null
}
const lng = c.lng
const lat = c.lat
return {
type: 'circle',
label,
color,
data: {
center: { lng, lat },
radius,
opacity,
width,
borderColor,
name
}
}
}
case 'sector': {
const c = parseLonLatPair(this.form.centerCorner)
const radiusKm = Number(this.form.radius)
const radius = radiusKm * 1000
const sb = Number(this.form.startBearing)
const eb = Number(this.form.endBearing)
if (!c || !Number.isFinite(radiusKm) || radiusKm <= 0) {
this.$message.error(this.$t('generateAirspace.errSector'))
return null
}
const lng = c.lng
const lat = c.lat
if (!Number.isFinite(sb) || !Number.isFinite(eb)) {
this.$message.error(this.$t('generateAirspace.errBearing'))
return null
}
const startAngle = this.bearingDegToSectorAngle(sb)
const endAngle = this.bearingDegToSectorAngle(eb)
return {
type: 'sector',
label,
color,
data: {
center: { lng, lat },
radius,
startAngle,
endAngle,
opacity,
width,
borderColor,
name
}
}
}
default:
return null
}
},
submit() {
const payload = this.buildPayload()
if (!payload) return
this.$emit('confirm', payload)
this.visible = false
}
}
}
</script>
<style scoped>
.input-with-unit {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.input-with-unit__num {
flex: 1;
min-width: 0;
}
.input-with-unit__suffix {
flex-shrink: 0;
font-size: 13px;
color: #606266;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

7
ruoyi-ui/src/views/dialogs/RouteEditDialog.vue

@ -24,7 +24,8 @@
</template>
<template v-else>
<el-button size="mini" @click="cancelWaypointsEdit"> </el-button>
<el-button type="primary" size="mini" class="blue-btn" @click="confirmWaypointsEdit"> </el-button>
<!-- 航点表编辑确定应直接落库否则刷新后会丢失 -->
<el-button type="primary" size="mini" class="blue-btn" @click="handleSave"> </el-button>
</template>
</template>
</div>
@ -259,7 +260,7 @@
<el-table-column label="转弯坡度(°)" min-width="100">
<template slot-scope="scope">
<span v-if="!waypointsEditMode">{{ formatNum(scope.row.turnAngle) }}</span>
<el-input v-else v-model.number="scope.row.turnAngle" size="mini" placeholder="0" />
<el-input v-else v-model.number="scope.row.turnAngle" size="mini" placeholder="45" />
</template>
</el-table-column>
<el-table-column label="盘旋" width="72" align="center">
@ -774,7 +775,7 @@ export default {
alt: row.alt,
speed: speedVal,
startTime: this.minutesToStartTime(row.minutesFromK),
turnAngle: row.turnAngle != null ? row.turnAngle : 0,
turnAngle: row.turnAngle != null ? row.turnAngle : 45,
turnRadius,
pointType: row.pointType || null,
holdParams: row.holdParams || null,

13
ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue

@ -116,13 +116,9 @@
<el-input-number
v-model="formData.turnAngle"
controls-position="right"
placeholder="请输入转弯坡度"
placeholder="默认 45°"
class="full-width-input"
:disabled="formData.isBankDisabled"
></el-input-number>
<div v-if="formData.isBankDisabled" class="form-tip form-warn">
首尾航点坡度已锁定为 0不可编辑
</div>
</el-form-item>
<template v-if="isHoldWaypoint">
<el-form-item label="盘旋类型">
@ -217,7 +213,6 @@ export default {
segmentTargetSpeed: null,
currentIndex: -1,
totalPoints: 0,
isBankDisabled: false,
pointType: 'normal',
holdClockwise: true,
holdEdgeLengthKm: 20,
@ -268,7 +263,6 @@ export default {
initFormData() {
const index = this.waypoint.currentIndex !== undefined ? this.waypoint.currentIndex : -1;
const total = this.waypoint.totalPoints || 0;
const locked = (index === 0) || (total > 0 && index === total - 1);
const pt = (this.waypoint.pointType || this.waypoint.point_type) || 'normal';
let holdClockwise = true;
@ -305,8 +299,9 @@ export default {
segmentTargetSpeed: (disp && disp.segmentTargetSpeed != null) ? Number(disp.segmentTargetSpeed) : (this.waypoint.segmentTargetSpeed != null ? Number(this.waypoint.segmentTargetSpeed) : (this.waypoint.speed != null ? Number(this.waypoint.speed) : null)),
currentIndex: index,
totalPoints: total,
isBankDisabled: locked,
turnAngle: locked ? 0 : (this.waypoint.turnAngle != null ? Number(this.waypoint.turnAngle) : 45),
turnAngle: this.waypoint.turnAngle != null && this.waypoint.turnAngle !== ''
? Number(this.waypoint.turnAngle)
: 45,
pointType: pt,
holdClockwise,
holdEdgeLengthKm,

Loading…
Cancel
Save