Browse Source

Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw

# Conflicts:
#	ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomPlatformIconController.java
#	ruoyi-ui/src/lang/en.js
#	ruoyi-ui/src/lang/zh.js
ctw
cuitw 5 days ago
parent
commit
06f7245f7a
  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. 34
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  13. 133
      ruoyi-ui/src/views/cesiumMap/index.vue
  14. 33
      ruoyi-ui/src/views/childRoom/GanttDrawer.vue
  15. 5
      ruoyi-ui/src/views/childRoom/TopHeader.vue
  16. 233
      ruoyi-ui/src/views/childRoom/index.vue
  17. 402
      ruoyi-ui/src/views/dialogs/GenerateAirspaceDialog.vue
  18. 3
      ruoyi-ui/src/views/dialogs/RouteEditDialog.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')")
@ -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 = []

34
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>
@ -306,8 +315,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 +325,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 +335,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" />
@ -759,6 +768,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
@ -812,6 +832,10 @@ export default {
this.$emit('adjust-airspace-position')
},
handleToggleAirspaceLock() {
this.$emit('toggle-airspace-lock')
},
handleEditAirspaceName() {
const name = prompt('请输入图形名称:', this.entityData.name || '')
if (name !== null) {

133
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"
@ -2541,8 +2542,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 便
@ -3231,6 +3233,21 @@ export default {
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 +4567,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;
@ -9642,6 +9660,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 +10124,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 +10286,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,6 +11795,11 @@ 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
@ -12115,6 +12170,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%
@ -12334,6 +12394,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 +12495,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 +12640,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
@ -12659,7 +12732,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 +12745,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 +12755,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 +12768,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 +12788,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 +12880,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 +12903,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) {
@ -13485,7 +13563,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 +13611,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 +13657,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 +13765,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)
@ -15016,10 +15099,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 +15114,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 {

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')

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

@ -153,6 +153,7 @@
@load-aero-chart="loadAeroChart"
@start-power-zone-drawing="startPowerZoneDrawing"
@threat-zone="threatZone"
@generate-airspace="openGenerateAirspaceDialog"
@route-calculation="routeCalculation"
@conflict-display="conflictDisplay"
@data-materials="dataMaterials"
@ -396,6 +397,12 @@
@save="savePageLayout"
/>
<!-- 按坐标生成空域与手绘同一套 frontend_drawings 持久化 -->
<generate-airspace-dialog
v-model="showGenerateAirspaceDialog"
@confirm="handleGenerateAirspaceConfirm"
/>
<!-- 导入平台弹窗 -->
<PlatformImportDialog
:visible.sync="showImportDialog"
@ -535,6 +542,7 @@ import ExternalParamsDialog from '@/views/dialogs/ExternalParamsDialog'
import PageLayoutDialog from '@/views/dialogs/PageLayoutDialog'
import KTimeSetDialog from '@/views/dialogs/KTimeSetDialog'
import UserProfileDialog from '@/views/dialogs/UserProfileDialog'
import GenerateAirspaceDialog from '@/views/dialogs/GenerateAirspaceDialog'
import LeftMenu from './LeftMenu'
import RightPanel from './RightPanel'
import BottomLeftPanel from './BottomLeftPanel'
@ -588,6 +596,7 @@ export default {
PageLayoutDialog,
KTimeSetDialog,
UserProfileDialog,
GenerateAirspaceDialog,
LeftMenu,
RightPanel,
BottomLeftPanel,
@ -630,6 +639,7 @@ export default {
showExternalParamsDialog: false,
currentExternalParams: {},
showPageLayoutDialog: false,
showGenerateAirspaceDialog: false,
menuPosition: 'left',
showNameDialog: false,
newRouteName: '',
@ -1384,7 +1394,16 @@ export default {
return;
}
const others = list.filter(w => w.id !== newWp.id);
others.sort((a, b) => (Number(a.seq) || 0) - (Number(b.seq) || 0));
others.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);
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);
});
const reordered = [...others.slice(0, insertIndex), newWp, ...others.slice(insertIndex)];
const routeInListFirst = this.routes.find(r => r.id === routeId);
if (routeInListFirst) routeInListFirst.waypoints = reordered;
@ -1434,7 +1453,16 @@ export default {
this.$message.warning('刷新后未拿到航线航点');
return;
}
const sortedWaypoints = updated.waypoints.slice().sort((a, b) => (Number(a.seq) || 0) - (Number(b.seq) || 0));
const sortedWaypoints = updated.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);
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);
});
updated.waypoints = sortedWaypoints;
const routeInList = this.routes.find(r => r.id === routeId);
if (routeInList) routeInList.waypoints = sortedWaypoints;
@ -1516,6 +1544,7 @@ export default {
turnAngle = preserveTurnAngle();
}
try {
// startTime K+00:00:00 min(K)
const payload = {
id: wp.id,
routeId,
@ -1525,9 +1554,9 @@ export default {
lng: wp.lng,
alt: wp.alt,
speed: wp.speed,
startTime: wp.startTime != null && wp.startTime !== '' ? wp.startTime : 'K+00:00:00',
turnAngle,
pointType
pointType,
...(wp.startTime != null && wp.startTime !== '' ? { startTime: wp.startTime } : {})
};
if (holdParams != null) payload.holdParams = holdParams;
else payload.holdParams = '';
@ -1568,7 +1597,8 @@ export default {
}
this.$refs.cesiumMap.removeRouteById(routeId);
this.$refs.cesiumMap.renderRouteWaypoints(r.waypoints, routeId, r.platformId, r.platform, this.parseRouteStyle(r.attributes));
this.$nextTick(() => this.updateDeductionPositions());
// startTime/hold min/max deductionMinutesFromK
this.$nextTick(() => this.updateTimeFromProgress());
if (roomId && r.waypoints && r.waypoints.length > 0) {
this.updateMissilePositionsAfterRouteEdit(roomId, routeId, r.platformId != null ? r.platformId : 0, r.waypoints);
}
@ -1638,9 +1668,9 @@ export default {
lng: wp.lng,
alt: wp.alt,
speed: wp.speed != null ? wp.speed : 800,
startTime: wp.startTime != null && wp.startTime !== '' ? wp.startTime : 'K+00:00:00',
turnAngle: wp.turnAngle != null && wp.turnAngle !== '' ? Number(wp.turnAngle) : 0,
pointType: (wp.pointType || wp.point_type || 'hold_circle')
pointType: (wp.pointType || wp.point_type || 'hold_circle'),
...(wp.startTime != null && wp.startTime !== '' ? { startTime: wp.startTime } : {})
};
payload.holdParams = JSON.stringify(nextHoldParamsObj);
if (wp.segmentMode != null) payload.segmentMode = wp.segmentMode;
@ -1678,7 +1708,7 @@ export default {
}
this.$refs.cesiumMap.removeRouteById(routeId);
this.$refs.cesiumMap.renderRouteWaypoints(r.waypoints, routeId, r.platformId, r.platform, this.parseRouteStyle(r.attributes));
this.$nextTick(() => this.updateDeductionPositions());
this.$nextTick(() => this.updateTimeFromProgress());
}
}
this.$message.success(`盘旋速度已更新为 ${Math.round(targetSpeed * 10) / 10} km/h`);
@ -1839,6 +1869,21 @@ export default {
this.$message.error('未找到对应航点');
return;
}
// // displayStyle
// /
const getSegMode = (w) => (w?.segmentMode ?? w?.displayStyle?.segmentMode ?? null);
const getSegTargetMinutes = (w) => (w?.segmentTargetMinutes ?? w?.displayStyle?.segmentTargetMinutes ?? null);
const getSegTargetSpeed = (w) => (w?.segmentTargetSpeed ?? w?.displayStyle?.segmentTargetSpeed ?? null);
const wpSegMode = getSegMode(wp);
const wpSegTargetMinutes = getSegTargetMinutes(wp);
const wpSegTargetSpeed = getSegTargetSpeed(wp);
const segTargetMinutesNum = wpSegTargetMinutes != null ? Number(wpSegTargetMinutes) : null;
const fixedTimeStartTime = (wpSegMode === 'fixed_time'
&& wpSegTargetMinutes != null
&& wpSegTargetMinutes !== ''
&& Number.isFinite(segTargetMinutesNum))
? this.minutesToStartTimeWithSeconds(segTargetMinutesNum)
: null;
const payload = {
id: wp.id,
routeId: wp.routeId != null ? wp.routeId : routeId,
@ -1849,14 +1894,19 @@ export default {
// 5000 -> 4999.999...
alt: wp.alt,
speed: wp.speed,
startTime: (wp.startTime != null && wp.startTime !== '') ? wp.startTime : 'K+00:00:00',
// fixed_time segmentTargetMinutes startTime startTime wait /
startTime: fixedTimeStartTime != null
? fixedTimeStartTime
: (wp.startTime != null && wp.startTime !== '') ? wp.startTime : 'K+00:00:00',
turnAngle: wp.turnAngle
};
if (wp.pointType != null) payload.pointType = wp.pointType;
if (wp.holdParams != null) payload.holdParams = wp.holdParams;
if (wp.labelFontSize != null) payload.labelFontSize = wp.labelFontSize;
if (wp.labelColor != null) payload.labelColor = wp.labelColor;
if (wp.segmentMode != null) payload.segmentMode = wp.segmentMode;
if (wpSegMode != null) payload.segmentMode = wpSegMode;
if (wpSegTargetMinutes != null && wpSegTargetMinutes !== '') payload.segmentTargetMinutes = wpSegTargetMinutes;
if (wpSegTargetSpeed != null && wpSegTargetSpeed !== '') payload.segmentTargetSpeed = wpSegTargetSpeed;
if (wp.color != null) payload.color = wp.color;
if (wp.pixelSize != null) payload.pixelSize = wp.pixelSize;
if (wp.outlineColor != null) payload.outlineColor = wp.outlineColor;
@ -1884,12 +1934,16 @@ export default {
{ lat: merged.lat, lng: merged.lng, alt: merged.alt }
);
const prevMinutes = this.waypointStartTimeToMinutesDecimal(prev.startTime);
if (prev.segmentMode === 'fixed_speed') {
const speedKmh = Number(prev.segmentTargetSpeed ?? prev.speed) || 800;
const prevSegMode = getSegMode(prev);
const prevSegTargetSpeed = getSegTargetSpeed(prev);
const mergedSegMode = getSegMode(merged);
const mergedSegTargetMinutes = getSegTargetMinutes(merged);
if (prevSegMode === 'fixed_speed') {
const speedKmh = Number(prevSegTargetSpeed ?? prev.speed) || 800;
const newMinutesFromK = prevMinutes + (distM / 1000) / speedKmh * 60;
const newStartTime = this.minutesToStartTimeWithSeconds(newMinutesFromK);
const startPayload = { ...merged, startTime: newStartTime };
if (merged.segmentMode != null) startPayload.segmentMode = merged.segmentMode;
if (mergedSegMode != null) startPayload.segmentMode = mergedSegMode;
try {
const r2 = await updateWaypoints(startPayload, roomIdParam);
if (r2.code === 200) {
@ -1903,14 +1957,16 @@ export default {
} catch (e) {
console.warn('定速重算相对K时失败', e);
}
} else if (merged.segmentMode === 'fixed_time') {
const currMinutes = (merged.segmentTargetMinutes != null && merged.segmentTargetMinutes !== '') ? Number(merged.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(merged.startTime);
} else if (mergedSegMode === 'fixed_time') {
const currMinutes = (mergedSegTargetMinutes != null && mergedSegTargetMinutes !== '')
? Number(mergedSegTargetMinutes)
: this.waypointStartTimeToMinutesDecimal(merged.startTime);
const deltaMin = currMinutes - prevMinutes;
if (deltaMin > 0.001) {
const newSpeedKmh = (distM / 1000) / (deltaMin / 60);
const speedVal = Math.round(newSpeedKmh * 10) / 10;
const speedPayload = { ...prev, speed: speedVal };
if (prev.segmentMode != null) speedPayload.segmentMode = prev.segmentMode;
if (prevSegMode != null) speedPayload.segmentMode = prevSegMode;
try {
const r2 = await updateWaypoints(speedPayload, roomIdParam);
if (r2.code === 200) {
@ -1931,19 +1987,26 @@ export default {
// 使 K
if (idx >= 0 && idx < waypoints.length - 1) {
const next = waypoints[idx + 1];
if (next.segmentMode === 'fixed_time') {
const nextSegMode = getSegMode(next);
const nextSegTargetMinutes = getSegTargetMinutes(next);
if (nextSegMode === 'fixed_time') {
const distToNextM = this.segmentDistance(
{ lat: merged.lat, lng: merged.lng, alt: merged.alt },
{ lat: next.lat, lng: next.lng, alt: next.alt }
);
const currMinutes = this.waypointStartTimeToMinutesDecimal(merged.startTime);
const nextMinutes = (next.segmentTargetMinutes != null && next.segmentTargetMinutes !== '') ? Number(next.segmentTargetMinutes) : this.waypointStartTimeToMinutesDecimal(next.startTime);
const mergedSegTargetMinutes = getSegTargetMinutes(merged);
const currMinutes = (mergedSegTargetMinutes != null && mergedSegTargetMinutes !== '')
? Number(mergedSegTargetMinutes)
: this.waypointStartTimeToMinutesDecimal(merged.startTime);
const nextMinutes = (nextSegTargetMinutes != null && nextSegTargetMinutes !== '')
? Number(nextSegTargetMinutes)
: this.waypointStartTimeToMinutesDecimal(next.startTime);
const deltaMin = nextMinutes - currMinutes;
if (deltaMin > 0.001) {
const newSpeedKmh = (distToNextM / 1000) / (deltaMin / 60);
const speedVal = Math.round(newSpeedKmh * 10) / 10;
const currPayload = { ...merged, speed: speedVal };
if (merged.segmentMode != null) currPayload.segmentMode = merged.segmentMode;
if (wpSegMode != null) currPayload.segmentMode = wpSegMode;
if (merged.labelFontSize != null) currPayload.labelFontSize = merged.labelFontSize;
if (merged.labelColor != null) currPayload.labelColor = merged.labelColor;
if (merged.color != null) currPayload.color = merged.color;
@ -1973,6 +2036,11 @@ export default {
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data);
} catch (_) {}
}
// routes routes
const routeInList = this.routes.find(r => r.id === routeId);
if (routeInList && routeInList.waypoints !== waypoints) {
this.$set(routeInList, 'waypoints', waypoints);
}
this.$refs.cesiumMap.renderRouteWaypoints(
waypoints,
routeId,
@ -2821,7 +2889,16 @@ export default {
platform: item.platform,
attributes: item.attributes,
points: item.waypoints ? item.waypoints.length : 0,
waypoints: item.waypoints || [],
waypoints: (item.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);
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);
}),
conflict: false,
scenarioId: item.scenarioId
}));
@ -4883,6 +4960,29 @@ export default {
this.$message.success('威胁区');
},
openGenerateAirspaceDialog() {
if (!this.currentRoomId) {
this.$message.warning(this.$t('generateAirspace.needRoom'));
return;
}
this.showGenerateAirspaceDialog = true;
},
handleGenerateAirspaceConfirm(payload) {
const map = this.$refs.cesiumMap;
if (!map || typeof map.importEntity !== 'function') {
this.$message.error(this.$t('generateAirspace.errImport'));
return;
}
try {
map.importEntity(payload);
this.$message.success(this.$t('generateAirspace.successMsg'));
} catch (e) {
console.error(e);
this.$message.error(this.$t('generateAirspace.errImport'));
}
},
//
routeCalculation() {
this.$message.success('航线计算');
@ -5102,7 +5202,16 @@ export default {
if (![oldStart, oldEnd, newStart, newEnd].every(Number.isFinite) || newEnd <= newStart) return;
const roomId = this.getRouteOperationRoomId(route);
const roomIdParam = roomId != null ? { roomId } : {};
const oldWpSorted = (route.waypoints || []).slice().sort((a, b) => (Number(a.seq) || 0) - (Number(b.seq) || 0));
const oldWpSorted = (route.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);
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);
});
const oldToNewById = {};
let prevNewMinutes = null;
oldWpSorted.forEach(wp => {
@ -5461,7 +5570,8 @@ export default {
/** 将航点 startTime 字符串转为相对 K 的分钟数 */
waypointStartTimeToMinutes(s) {
if (!s || typeof s !== 'string') return 0;
const m = s.match(/K([+-])(\d{2}):(\d{2})/);
// 1~2 K+0:18:00 K+00:18:00
const m = s.match(/K([+-])(\d{1,2}):(\d{2})(?::(\d{2}))?/);
if (!m) return 0;
const sign = m[1] === '+' ? 1 : -1;
const h = parseInt(m[2], 10);
@ -5471,7 +5581,8 @@ export default {
/** 将 startTime(如 K+00:19:30)转为相对 K 的分钟数(含秒,保留小数) */
waypointStartTimeToMinutesDecimal(s) {
if (!s || typeof s !== 'string') return 0;
const m = s.match(/K([+-])(\d{2}):(\d{2})(?::(\d{2}))?/);
// 1~2 K+0:18:00 K+00:18:00
const m = s.match(/K([+-])(\d{1,2}):(\d{2})(?::(\d{2}))?/);
if (!m) return 0;
const sign = m[1] === '+' ? 1 : -1;
const h = parseInt(m[2], 10);
@ -5577,6 +5688,18 @@ export default {
buildRouteTimeline(waypoints, globalMin, globalMax, pathData) {
const warnings = [];
if (!waypoints || waypoints.length === 0) return { segments: [], warnings };
// points线/ seq
// seq / 0
waypoints = 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);
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);
});
const points = waypoints.map((wp, idx) => ({
lng: parseFloat(wp.lng),
lat: parseFloat(wp.lat),
@ -5587,6 +5710,11 @@ export default {
}));
const hasHold = points.some(p => p.isHold);
const allSame = points.every(p => p.minutes === points[0].minutes);
// minutes startTime /
// segments start/endTime
// getPositionFromTimeline /
// K+0 / minutes
// segments/
if (allSame && points.length > 1 && !hasHold) {
const span = Math.max(globalMax - globalMin, 1);
points.forEach((p, i) => {
@ -5917,8 +6045,15 @@ export default {
/** 从时间轴中取当前推演时间对应的位置;支持 fly/wait/hold,hold 沿 holdPath 弧长插值 */
getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices) {
if (!segments || segments.length === 0) return null;
if (minutesFromK <= segments[0].startTime) return segments[0].startPos;
const last = segments[segments.length - 1];
// buildRouteTimeline push wait push fly/hold
// [startTime, endTime) wait
const sortedSegments = segments.slice().sort((a, b) => {
const ds = Number(a.startTime) - Number(b.startTime);
if (ds !== 0) return ds;
return Number(a.endTime) - Number(b.endTime);
});
if (minutesFromK <= sortedSegments[0].startTime) return sortedSegments[0].startPos;
const last = sortedSegments[sortedSegments.length - 1];
if (minutesFromK >= last.endTime) {
if (last.type === 'wait' && path && segmentEndIndices && last.legIndex != null && last.legIndex < segmentEndIndices.length && path[segmentEndIndices[last.legIndex]]) {
return path[segmentEndIndices[last.legIndex]];
@ -5926,10 +6061,13 @@ export default {
if (last.type === 'hold' && last.holdPath && last.holdPath.length) return last.holdPath[last.holdPath.length - 1];
return last.endPos;
}
for (let i = 0; i < segments.length; i++) {
const s = segments[i];
if (minutesFromK < s.endTime) {
const t = Math.max(0, Math.min(1, (minutesFromK - s.startTime) / (s.endTime - s.startTime)));
for (let i = 0; i < sortedSegments.length; i++) {
const s = sortedSegments[i];
if (minutesFromK >= s.startTime && minutesFromK < s.endTime) {
const duration = s.endTime - s.startTime;
const t = duration !== 0
? Math.max(0, Math.min(1, (minutesFromK - s.startTime) / duration))
: 0;
if (s.type === 'wait') {
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) {
const endIdx = segmentEndIndices[s.legIndex];
@ -5994,6 +6132,17 @@ export default {
buildPathDataForRouteTimeline(waypoints, routeId) {
const cesiumMap = this.$refs.cesiumMap;
if (!waypoints || waypoints.length === 0 || !cesiumMap || !cesiumMap.getRoutePathWithSegmentIndices) return null;
// buildRouteTimeline seq K min(K)
waypoints = 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);
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);
});
const cachedEllipse = (routeId != null && cesiumMap._routeHoldEllipseParamsByRoute && cesiumMap._routeHoldEllipseParamsByRoute[routeId])
? cesiumMap._routeHoldEllipseParamsByRoute[routeId]
: {};
@ -6008,6 +6157,16 @@ export default {
/** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧/盘旋弧路径运动;盘旋半径由系统根据 k+10 落点反算,使平滑落在切点。routeId 可选,传入时会把计算半径同步给地图以实时渲染盘旋轨迹与切点进入。返回 { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment } */
getPositionAtMinutesFromK(waypoints, minutesFromK, globalMin, globalMax, routeId) {
if (!waypoints || waypoints.length === 0) return { position: null, nextPosition: null, previousPosition: null, warnings: [], earlyArrivalLegs: [], currentSegment: null };
waypoints = 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);
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);
});
const cesiumMap = this.$refs.cesiumMap;
let pathData = this.buildPathDataForRouteTimeline(waypoints, routeId);
let { segments, warnings, earlyArrivalLegs } = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData);
@ -6145,7 +6304,19 @@ export default {
const { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes, routeId);
if (warnings && warnings.length) allWarnings.push(...warnings);
if (position) {
const directionPoint = nextPosition || previousPosition;
// nextPosition/ nextPosition position heading=0
const isSamePos = (p1, p2) => {
if (!p1 || !p2) return false;
const dLng = Number(p1.lng) - Number(p2.lng);
const dLat = Number(p1.lat) - Number(p2.lat);
const dAlt = (Number(p1.alt != null ? p1.alt : 0) - Number(p2.alt != null ? p2.alt : 0));
// /alt
return Math.abs(dLng) < 1e-8 && Math.abs(dLat) < 1e-8 && Math.abs(dAlt) < 2;
};
let directionPoint = nextPosition;
if (nextPosition && isSamePos(position, nextPosition)) directionPoint = null;
if (!directionPoint && previousPosition && !isSamePos(position, previousPosition)) directionPoint = previousPosition;
if (!directionPoint) directionPoint = nextPosition || previousPosition;
const labelData = {
name: (route.platform && route.platform.name) ? route.platform.name : '平台',
altitude: position.alt != null ? Number(position.alt) : 0,

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>

3
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>

Loading…
Cancel
Save