Compare commits

...

91 Commits
master ... mh

Author SHA1 Message Date
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
cuitw 7aa0690206 截图展示,以及各种功能 6 days ago
cuitw 11c69186c7 Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 6 days ago
menghao 96db7d8cb0 聊天室父子房间在线用户显示、白板区平台颜色大小、用户信息修改 1 week ago
cuitw 0f54e385c7 Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 2 weeks ago
cuitw e8b9fc6cc8 样式修改 2 weeks ago
menghao 935cb97a2d 编辑航线弹窗样式修改、删除航点列表 2 weeks ago
menghao 639dee250c Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 2 weeks ago
cuitw 2b3cacd81d 最新 2 weeks ago
cuitw 5bc306606e Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 2 weeks ago
menghao 0f498913d8 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 2 weeks ago
menghao c1c6712bb9 冲突检测圆柱体、甘特图拖拽改时间、盘旋修复 2 weeks ago
cuitw dd42f554cc 实时航向,测量海里,框选平台移动,航线拆分 2 weeks ago
menghao 7e650dd463 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 2 weeks ago
cuitw 1a19a74a3b Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 2 weeks ago
cuitw 7b08253427 各种内容修正 2 weeks ago
menghao 67fc6f14c8 操作日志bug修复、单个平台叠加威力区探测区 2 weeks ago
cuitw ae916fe8c0 Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 2 weeks ago
menghao e4e1cd88c8 冲突1.1 2 weeks ago
cuitw ff9846e949 Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 2 weeks ago
menghao 6bdf9f2530 冲突1.1 2 weeks ago
cuitw 126db9a80a Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 3 weeks ago
menghao 878e98d4f8 冲突卡死bug修复 3 weeks ago
cuitw 69346d13d0 Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 3 weeks ago
menghao bb80bd9bce Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 3 weeks ago
menghao c5b3c8b8d9 冲突1.0 3 weeks ago
menghao bffabd80f8 冲突1.0 3 weeks ago
cuitw ddbaa98c82 定时点定速点 3 weeks ago
menghao 9858561654 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 3 weeks ago
menghao 8d9f2f74c7 操作日志记录与回滚、甘特图逻辑重绘 3 weeks ago
cuitw ec983ea06c 两种盘旋的轨迹修正 3 weeks ago
menghao 47ccdea3aa Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 3 weeks ago
cuitw 89750c649b Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 3 weeks ago
cuitw 37c517b086 航线的导入导出 3 weeks ago
menghao ef58d663b5 盘旋圆、椭圆轨迹,时间轴悬浮展示时间,顺时针逆时针方向修正,定时定速点 3 weeks ago
cuitw afe9fc768d 白板功能 3 weeks ago
cuitw d67e853223 六步法前后端,加空域辅助线绘制 4 weeks ago
cuitw 9b939f4112 六步法前端 4 weeks ago
cuitw 9032009bea 六步法前端 4 weeks ago
cuitw 4b97960ad9 六步法任务的持久化 4 weeks ago
cuitw 53f5b0fbaa Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 4 weeks ago
menghao 0fdb456e2c 航线颜色等样式显示、右键菜单项显示修复、控制地图拖动小手图标 4 weeks ago
menghao 7678960b95 盘旋圆形、椭圆轨迹 4 weeks ago
cuitw b0ed224634 六步法初稿 4 weeks ago
menghao 51ecfaa815 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 1 month ago
cuitw 74c966ec82 联网内容修改 1 month ago
menghao 057b059b78 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 1 month ago
cuitw c375780052 联网内容修改 1 month ago
menghao 64fdd77170 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 1 month ago
cuitw 64dbbbe495 Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 1 month ago
menghao 347d791d39 编辑/选中状态2.0 1 month ago
cuitw c01e2a91a5 空域修改 1 month ago
menghao 3592696855 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 1 month ago
menghao 14783654fa 编辑/选中状态 1 month ago
cuitw 16a8b85051 联网的实时渲染 1 month ago
cuitw 40b9cbc1c8 聊天室 1 month ago
menghao 0d29497ee6 弹窗拖动 1 month ago
menghao 583d9fc13c 弹窗拖动 1 month ago
cuitw 13f0a035c7 初步联网功能 1 month ago
cuitw 3af95243c9 更新用 1 month ago
cuitw d42038c8e7 Merge branch 'lbj' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 1 month ago
cuitw 520a94ed71 4T功能 1 month ago
sd b0eda7277d 六步法和三卡 1 month ago
cuitw 9f0f3b1bf1 Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 1 month ago
cuitw 71e34912ea Merge branch 'lbj' of http://124.70.32.114:3100/woka/cesium-map-object into ctw 1 month ago
menghao ace4d1c6ce 图片上传路径统一,控制台类型不匹配报错修复 1 month ago
menghao 7681af9751 合并 1 month ago
menghao 1fbdb403f3 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 1 month ago
menghao f320853a1d 导弹发射,导弹数据存redis 1 month ago
cuitw 0ca5b849ba 4T功能 1 month ago
menghao 631def63d0 导弹发射,导弹数据存redis 1 month ago
cuitw e310220fce 4T功能 1 month ago
cuitw 76337b13be 威力区和探测区 1 month ago
cuitw f1d8206043 每个点可切换盘旋 1 month ago
sd 03d33118ce 测量与六步法优化 1 month ago
cuitw 8ade9f4da6 在航点的前后新增航点 1 month ago
ctw 516fd08b0a redis存储 1 month ago
ctw 9edef2f215 Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object 1 month ago
menghao ad7b0b1142 平台样式、标牌样式存redis 1 month ago
ctw 5a092bd197 Merge branch 'lbj' of http://124.70.32.114:3100/woka/cesium-map-object 1 month ago
ctw 6b40c47077 Merge branch 'mh' of http://124.70.32.114:3100/woka/cesium-map-object 1 month ago
menghao 46b77da38b 平台样式、标牌样式存redis 1 month ago
sd 1dd39bbda2 时间轴加数据库 1 month ago
menghao 9544fd9bb1 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 1 month ago
menghao f63b8d16a6 1 1 month ago
menghao b7ca5a89b8 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 1 month ago
sd 48fb8045a0 甘特图 1 month ago
  1. 3
      .vscode/settings.json
  2. 6
      package-lock.json
  3. 6
      ruoyi-admin/pom.xml
  4. 53
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/MissileParamsController.java
  5. 99
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/ObjectOperationLogController.java
  6. 82
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/PlatformLibController.java
  7. 61
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomPlatformIconController.java
  8. 92
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RouteWaypointsController.java
  9. 527
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java
  10. 163
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/WhiteboardController.java
  11. 2
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java
  12. 81
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysTimelineSegmentController.java
  13. 27
      ruoyi-admin/src/main/java/com/ruoyi/websocket/config/LoginUserPrincipal.java
  14. 37
      ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketChannelInterceptor.java
  15. 47
      ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketConfig.java
  16. 80
      ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketHandshakeHandler.java
  17. 348
      ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
  18. 106
      ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomMemberDTO.java
  19. 33
      ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomSessionInfo.java
  20. 51
      ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java
  21. 109
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomChatService.java
  22. 115
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomOnlineMemberBroadcastService.java
  23. 61
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomRoomStateService.java
  24. 158
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomWebSocketService.java
  25. 156
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WhiteboardRoomService.java
  26. 2
      ruoyi-admin/src/main/resources/application-druid.yml
  27. 8
      ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java
  28. 5
      ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java
  29. 89
      ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysTimelineSegment.java
  30. 14
      ruoyi-common/src/main/java/com/ruoyi/common/utils/ServletUtils.java
  31. 43
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java
  32. 2
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java
  33. 3
      ruoyi-framework/src/main/java/com/ruoyi/framework/security/handle/AuthenticationEntryPointImpl.java
  34. 30
      ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java
  35. 13
      ruoyi-system/src/main/java/com/ruoyi/system/domain/MissionScenario.java
  36. 88
      ruoyi-system/src/main/java/com/ruoyi/system/domain/ObjectOperationLog.java
  37. 47
      ruoyi-system/src/main/java/com/ruoyi/system/domain/RoomUserProfile.java
  38. 92
      ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java
  39. 34
      ruoyi-system/src/main/java/com/ruoyi/system/domain/Routes.java
  40. 92
      ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java
  41. 348
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/PlatformStyleDTO.java
  42. 23
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/ObjectOperationLogMapper.java
  43. 8
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PlatformLibMapper.java
  44. 12
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/RoomUserProfileMapper.java
  45. 12
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/RouteWaypointsMapper.java
  46. 22
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysTimelineSegmentMapper.java
  47. 33
      ruoyi-system/src/main/java/com/ruoyi/system/service/IObjectOperationLogService.java
  48. 5
      ruoyi-system/src/main/java/com/ruoyi/system/service/IRoomPlatformIconService.java
  49. 12
      ruoyi-system/src/main/java/com/ruoyi/system/service/IRoomUserProfileService.java
  50. 21
      ruoyi-system/src/main/java/com/ruoyi/system/service/ISysTimelineSegmentService.java
  51. 219
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ObjectOperationLogServiceImpl.java
  52. 5
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RoomPlatformIconServiceImpl.java
  53. 63
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RoomUserProfileServiceImpl.java
  54. 9
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RouteWaypointsServiceImpl.java
  55. 11
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysMenuServiceImpl.java
  56. 57
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysTimelineSegmentServiceImpl.java
  57. 59
      ruoyi-system/src/main/java/com/ruoyi/system/typehandler/WaypointDisplayStyleTypeHandler.java
  58. 3
      ruoyi-system/src/main/resources/mapper/system/MissionScenarioMapper.xml
  59. 56
      ruoyi-system/src/main/resources/mapper/system/ObjectOperationLogMapper.xml
  60. 19
      ruoyi-system/src/main/resources/mapper/system/PlatformLibMapper.xml
  61. 54
      ruoyi-system/src/main/resources/mapper/system/RoomUserProfileMapper.xml
  62. 25
      ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml
  63. 5
      ruoyi-system/src/main/resources/mapper/system/RoutesMapper.xml
  64. 77
      ruoyi-system/src/main/resources/mapper/system/SysTimelineSegmentMapper.xml
  65. 7
      ruoyi-ui/package.json
  66. 691
      ruoyi-ui/public/dataCard.html
  67. 1
      ruoyi-ui/public/element-ui.css
  68. BIN
      ruoyi-ui/public/fonts/element-icons.woff
  69. BIN
      ruoyi-ui/public/logo.png
  70. BIN
      ruoyi-ui/public/logo2.jpg
  71. 630
      ruoyi-ui/public/stepEditor.html
  72. 19
      ruoyi-ui/src/api/system/lib.js
  73. 23
      ruoyi-ui/src/api/system/objectLog.js
  74. 19
      ruoyi-ui/src/api/system/roomPlatformIcon.js
  75. 160
      ruoyi-ui/src/api/system/routes.js
  76. 53
      ruoyi-ui/src/api/system/timeline.js
  77. 22
      ruoyi-ui/src/api/system/waypoints.js
  78. 98
      ruoyi-ui/src/api/system/whiteboard.js
  79. 1
      ruoyi-ui/src/assets/icons/svg/T.svg
  80. 1
      ruoyi-ui/src/assets/icons/svg/shujukapian.svg
  81. BIN
      ruoyi-ui/src/assets/images/missile.png
  82. 40
      ruoyi-ui/src/assets/styles/element-ui.scss
  83. 2
      ruoyi-ui/src/assets/styles/element-variables.scss
  84. 6
      ruoyi-ui/src/assets/styles/ruoyi.scss
  85. 2
      ruoyi-ui/src/assets/styles/variables.scss
  86. 4
      ruoyi-ui/src/components/ThemePicker/index.vue
  87. 77
      ruoyi-ui/src/lang/en.js
  88. 77
      ruoyi-ui/src/lang/zh.js
  89. 2
      ruoyi-ui/src/layout/components/Settings/index.vue
  90. 8
      ruoyi-ui/src/layout/components/TagsView/index.vue
  91. 4
      ruoyi-ui/src/main.js
  92. 181
      ruoyi-ui/src/plugins/dialogDrag.js
  93. 9
      ruoyi-ui/src/router/index.js
  94. 41
      ruoyi-ui/src/store/modules/permission.js
  95. 2
      ruoyi-ui/src/store/modules/settings.js
  96. 569
      ruoyi-ui/src/utils/conflictDetection.js
  97. 14
      ruoyi-ui/src/utils/imageUrl.js
  98. 27
      ruoyi-ui/src/utils/request.js
  99. 130
      ruoyi-ui/src/utils/timelinePosition.js
  100. 334
      ruoyi-ui/src/utils/websocket.js

3
.vscode/settings.json

@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}

6
package-lock.json

@ -1,6 +0,0 @@
{
"name": "cesium-map-object",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

6
ruoyi-admin/pom.xml

@ -50,6 +50,12 @@
<version>3.42.0.0</version>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 核心模块-->
<dependency>
<groupId>com.ruoyi</groupId>

53
ruoyi-admin/src/main/java/com/ruoyi/web/controller/MissileParamsController.java

@ -0,0 +1,53 @@
package com.ruoyi.web.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.alibaba.fastjson2.JSON;
import org.springframework.data.redis.core.RedisTemplate;
/**
* 导弹发射参数角度距离Redis 存取
* key: missile:params:{roomId}:{routeId}:{platformId}, value: JSON
*/
@RestController
@RequestMapping("/system/missile-params")
public class MissileParamsController extends BaseController {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping
public AjaxResult get(Long roomId, Long routeId, Long platformId) {
if (roomId == null || routeId == null || platformId == null) {
return AjaxResult.error("参数不完整");
}
String key = "missile:params:" + roomId + ":" + routeId + ":" + platformId;
Object val = redisTemplate.opsForValue().get(key);
if (val != null) {
return success(JSON.parseObject(val.toString()));
}
return success();
}
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping
public AjaxResult save(@RequestBody java.util.Map<String, Object> body) {
Object roomId = body.get("roomId");
Object routeId = body.get("routeId");
Object platformId = body.get("platformId");
if (roomId == null || routeId == null || platformId == null) {
return AjaxResult.error("参数不完整");
}
String key = "missile:params:" + roomId + ":" + routeId + ":" + platformId;
redisTemplate.opsForValue().set(key, JSON.toJSONString(body));
return success();
}
}

99
ruoyi-admin/src/main/java/com/ruoyi/web/controller/ObjectOperationLogController.java

@ -0,0 +1,99 @@
package com.ruoyi.web.controller;
import java.util.List;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.service.IObjectOperationLogService;
/**
* 对象级操作日志航线/航点/平台分页查询回滚
*/
@RestController
@RequestMapping("/system/object-log")
public class ObjectOperationLogController extends BaseController {
@Autowired
private IObjectOperationLogService objectOperationLogService;
/**
* 分页查询对象级操作日志按房间
*/
@PreAuthorize("@ss.hasPermi('system:routes:list')")
@GetMapping("/list")
public TableDataInfo list(
@RequestParam(required = false) Long roomId,
@RequestParam(required = false) String operatorName,
@RequestParam(required = false) Integer operationType,
@RequestParam(required = false) String objectType) {
startPage();
ObjectOperationLog query = new ObjectOperationLog();
query.setRoomId(roomId);
query.setOperatorName(operatorName);
query.setOperationType(operationType);
query.setObjectType(objectType);
List<ObjectOperationLog> list = objectOperationLogService.selectPage(query);
return getDataTable(list);
}
/**
* 回滚到指定操作数据库 + Redis 同步
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping("/rollback")
public AjaxResult rollback(@RequestParam Long id) {
ObjectOperationLog origin = objectOperationLogService.selectById(id);
if (origin == null) {
return error("回滚失败:原始日志不存在");
}
boolean ok = objectOperationLogService.rollback(id);
if (!ok) {
return error("回滚失败:无快照或对象类型不支持");
}
// 记录一条“回滚”操作日志,便于审计
ObjectOperationLog rollbackLog = new ObjectOperationLog();
rollbackLog.setRoomId(origin.getRoomId());
rollbackLog.setOperatorId(getUserId());
rollbackLog.setOperatorName(getUsername());
rollbackLog.setOperationType(ObjectOperationLog.TYPE_ROLLBACK);
rollbackLog.setObjectType(origin.getObjectType());
rollbackLog.setObjectId(origin.getObjectId());
rollbackLog.setObjectName(origin.getObjectName());
rollbackLog.setDetail("回滚操作:基于日志ID=" + origin.getId() + " 的" + toOpText(origin.getOperationType()));
// 简要记录被回滚日志的快照,方便排查(可选)
rollbackLog.setSnapshotBefore(origin.getSnapshotBefore());
rollbackLog.setSnapshotAfter(origin.getSnapshotAfter());
objectOperationLogService.saveLog(rollbackLog);
return success();
}
private String toOpText(Integer type) {
if (type == null) return "";
switch (type) {
case ObjectOperationLog.TYPE_INSERT:
return "新增";
case ObjectOperationLog.TYPE_UPDATE:
return "修改";
case ObjectOperationLog.TYPE_DELETE:
return "删除";
case ObjectOperationLog.TYPE_SELECT:
return "选择";
case ObjectOperationLog.TYPE_ROLLBACK:
return "回滚";
default:
return "";
}
}
}

82
ruoyi-admin/src/main/java/com/ruoyi/web/controller/PlatformLibController.java

@ -10,7 +10,10 @@ import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.domain.PlatformLib;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IPlatformLibService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
@ -31,6 +34,9 @@ public class PlatformLibController extends BaseController
@Autowired
private IPlatformLibService platformLibService;
@Autowired
private IObjectOperationLogService objectOperationLogService;
/**
* 查询平台模版库列表
*/
@ -72,18 +78,38 @@ public class PlatformLibController extends BaseController
@PreAuthorize("@ss.hasPermi('system:lib:add')")
@Log(title = "平台模版库", businessType = BusinessType.INSERT)
@PostMapping("/add")
public AjaxResult add(PlatformLib platformLib, @RequestParam("file") MultipartFile file) throws IOException
public AjaxResult add(PlatformLib platformLib,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "roomId", required = false) Long roomId) throws IOException
{
// 判断前端是否有文件传过来
if (file != null && !file.isEmpty())
{
String fileName = FileUploadUtils.upload(RuoYiConfig.getProfile(), file);
// 与通用上传一致:存到 profile/upload/年/月/日/ 下,统一存储路径
String fileName = FileUploadUtils.upload(RuoYiConfig.getUploadPath(), file);
// 把这个路径存入实体类的 iconUrl 属性,对应数据库 icon_url 字段
platformLib.setIconUrl(fileName);
}
// 执行原有的插入逻辑
return toAjax(platformLibService.insertPlatformLib(platformLib));
int rows = platformLibService.insertPlatformLib(platformLib);
if (rows > 0) {
try {
ObjectOperationLog log = new ObjectOperationLog();
log.setRoomId(roomId);
log.setOperatorId(getUserId());
log.setOperatorName(getUsername());
log.setOperationType(ObjectOperationLog.TYPE_INSERT);
log.setObjectType(ObjectOperationLog.OBJ_PLATFORM);
log.setObjectId(platformLib.getId() != null ? String.valueOf(platformLib.getId()) : null);
log.setObjectName(platformLib.getName());
log.setDetail("新增平台模板:" + platformLib.getName());
log.setSnapshotAfter(JSON.toJSONString(platformLib));
objectOperationLogService.saveLog(log);
} catch (Exception e) {
logger.warn("记录平台新增操作日志失败", e);
}
}
return toAjax(rows);
}
/**
@ -92,9 +118,30 @@ public class PlatformLibController extends BaseController
@PreAuthorize("@ss.hasPermi('system:lib:edit')")
@Log(title = "平台模版库", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody PlatformLib platformLib)
public AjaxResult edit(@RequestBody PlatformLib platformLib,
@RequestParam(value = "roomId", required = false) Long roomId)
{
return toAjax(platformLibService.updatePlatformLib(platformLib));
PlatformLib before = platformLib.getId() != null ? platformLibService.selectPlatformLibById(platformLib.getId()) : null;
int rows = platformLibService.updatePlatformLib(platformLib);
if (rows > 0 && before != null) {
try {
ObjectOperationLog log = new ObjectOperationLog();
log.setRoomId(roomId);
log.setOperatorId(getUserId());
log.setOperatorName(getUsername());
log.setOperationType(ObjectOperationLog.TYPE_UPDATE);
log.setObjectType(ObjectOperationLog.OBJ_PLATFORM);
log.setObjectId(String.valueOf(platformLib.getId()));
log.setObjectName(platformLib.getName());
log.setDetail("修改平台模板:" + platformLib.getName());
log.setSnapshotBefore(JSON.toJSONString(before));
log.setSnapshotAfter(JSON.toJSONString(platformLib));
objectOperationLogService.saveLog(log);
} catch (Exception e) {
logger.warn("记录平台修改操作日志失败", e);
}
}
return toAjax(rows);
}
/**
@ -103,8 +150,29 @@ public class PlatformLibController extends BaseController
@PreAuthorize("@ss.hasPermi('system:lib:remove')")
@Log(title = "平台模版库", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
public AjaxResult remove(@PathVariable Long[] ids,
@RequestParam(value = "roomId", required = false) Long roomId)
{
for (Long id : ids) {
PlatformLib before = platformLibService.selectPlatformLibById(id);
if (before != null) {
try {
ObjectOperationLog log = new ObjectOperationLog();
log.setRoomId(roomId);
log.setOperatorId(getUserId());
log.setOperatorName(getUsername());
log.setOperationType(ObjectOperationLog.TYPE_DELETE);
log.setObjectType(ObjectOperationLog.OBJ_PLATFORM);
log.setObjectId(String.valueOf(id));
log.setObjectName(before.getName());
log.setDetail("删除平台模板:" + before.getName());
log.setSnapshotBefore(JSON.toJSONString(before));
objectOperationLogService.saveLog(log);
} catch (Exception e) {
logger.warn("记录平台删除操作日志失败", e);
}
}
}
return toAjax(platformLibService.deletePlatformLibByIds(ids));
}
}

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

@ -2,12 +2,14 @@ package com.ruoyi.web.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
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.RoomPlatformIcon;
import com.ruoyi.system.service.IRoomPlatformIconService;
@ -21,17 +23,50 @@ public class RoomPlatformIconController extends BaseController {
@Autowired
private IRoomPlatformIconService roomPlatformIconService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 按房间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')")
@ -53,12 +88,26 @@ public class RoomPlatformIconController extends BaseController {
}
/**
* 删除
* 删除同步删除该实例在 Redis 中的平台样式避免残留威力区/探测区
*/
@PreAuthorize("@ss.hasPermi('system:roomPlatformIcon:remove')")
@Log(title = "房间地图平台图标", businessType = BusinessType.DELETE)
@DeleteMapping("/{id}")
public AjaxResult remove(@PathVariable Long id) {
return toAjax(roomPlatformIconService.deleteById(id));
@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);
}
return toAjax(rows);
}
}

92
ruoyi-admin/src/main/java/com/ruoyi/web/controller/RouteWaypointsController.java

@ -12,12 +12,16 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.domain.RouteWaypoints;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IRouteWaypointsService;
import org.springframework.web.bind.annotation.RequestParam;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
@ -34,10 +38,13 @@ public class RouteWaypointsController extends BaseController
@Autowired
private IRouteWaypointsService routeWaypointsService;
@Autowired
private IObjectOperationLogService objectOperationLogService;
/**
* 查询航线具体航点明细列表
*/
@PreAuthorize("@ss.hasPermi('system:waypoints:list')")
@PreAuthorize("@ss.hasPermi('system:routes:list')")
@GetMapping("/list")
public TableDataInfo list(RouteWaypoints routeWaypoints)
{
@ -49,7 +56,7 @@ public class RouteWaypointsController extends BaseController
/**
* 导出航线具体航点明细列表
*/
@PreAuthorize("@ss.hasPermi('system:waypoints:export')")
@PreAuthorize("@ss.hasPermi('system:routes:export')")
@Log(title = "航线具体航点明细", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, RouteWaypoints routeWaypoints)
@ -62,7 +69,7 @@ public class RouteWaypointsController extends BaseController
/**
* 获取航线具体航点明细详细信息
*/
@PreAuthorize("@ss.hasPermi('system:waypoints:query')")
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
@ -71,34 +78,95 @@ public class RouteWaypointsController extends BaseController
/**
* 新增航线具体航点明细
* @param roomId 可选房间ID时记录到对象级操作日志
*/
@PreAuthorize("@ss.hasPermi('system:waypoints:add')")
@PreAuthorize("@ss.hasPermi('system:routes:add')")
@Log(title = "航线具体航点明细", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody RouteWaypoints routeWaypoints)
public AjaxResult add(@RequestBody RouteWaypoints routeWaypoints, @RequestParam(required = false) Long roomId)
{
return toAjax(routeWaypointsService.insertRouteWaypoints(routeWaypoints));
int rows = routeWaypointsService.insertRouteWaypoints(routeWaypoints);
if (rows > 0) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_INSERT);
opLog.setObjectType(ObjectOperationLog.OBJ_WAYPOINT);
opLog.setObjectId(routeWaypoints.getId() != null ? String.valueOf(routeWaypoints.getId()) : null);
opLog.setObjectName(routeWaypoints.getName() != null ? routeWaypoints.getName() : "航点");
opLog.setDetail("新增航点:" + (routeWaypoints.getName() != null ? routeWaypoints.getName() : ""));
opLog.setSnapshotAfter(JSON.toJSONString(routeWaypoints));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
return toAjax(rows);
}
/**
* 修改航线具体航点明细
* 修改航线具体航点明细复用航线编辑权限航点属于航线的一部分
* @param roomId 可选房间ID时记录到对象级操作日志
*/
@PreAuthorize("@ss.hasPermi('system:waypoints:edit')")
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@Log(title = "航线具体航点明细", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody RouteWaypoints routeWaypoints)
public AjaxResult edit(@RequestBody RouteWaypoints routeWaypoints, @RequestParam(required = false) Long roomId)
{
return toAjax(routeWaypointsService.updateRouteWaypoints(routeWaypoints));
RouteWaypoints before = routeWaypoints.getId() != null ? routeWaypointsService.selectRouteWaypointsById(routeWaypoints.getId()) : null;
int rows = routeWaypointsService.updateRouteWaypoints(routeWaypoints);
if (rows > 0 && before != null) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_UPDATE);
opLog.setObjectType(ObjectOperationLog.OBJ_WAYPOINT);
opLog.setObjectId(String.valueOf(routeWaypoints.getId()));
opLog.setObjectName(routeWaypoints.getName() != null ? routeWaypoints.getName() : "航点");
opLog.setDetail("修改航点:" + (routeWaypoints.getName() != null ? routeWaypoints.getName() : routeWaypoints.getId()));
opLog.setSnapshotBefore(JSON.toJSONString(before));
opLog.setSnapshotAfter(JSON.toJSONString(routeWaypoints));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
return toAjax(rows);
}
/**
* 删除航线具体航点明细
* @param roomId 可选房间ID时记录到对象级操作日志
*/
@PreAuthorize("@ss.hasPermi('system:waypoints:remove')")
@PreAuthorize("@ss.hasPermi('system:routes:remove')")
@Log(title = "航线具体航点明细", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
public AjaxResult remove(@PathVariable Long[] ids, @RequestParam(required = false) Long roomId)
{
for (Long id : ids) {
RouteWaypoints before = routeWaypointsService.selectRouteWaypointsById(id);
if (before != null) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_DELETE);
opLog.setObjectType(ObjectOperationLog.OBJ_WAYPOINT);
opLog.setObjectId(String.valueOf(id));
opLog.setObjectName(before.getName() != null ? before.getName() : "航点");
opLog.setDetail("删除航点:" + (before.getName() != null ? before.getName() : id));
opLog.setSnapshotBefore(JSON.toJSONString(before));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
}
return toAjax(routeWaypointsService.deleteRouteWaypointsByIds(ids));
}
}

527
ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java

@ -11,15 +11,27 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.domain.Routes;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IRoutesService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.system.domain.dto.PlatformStyleDTO;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.beans.factory.annotation.Qualifier;
import java.util.ArrayList;
import java.util.Set;
/**
* 实体部署与航线Controller
@ -34,6 +46,368 @@ public class RoutesController extends BaseController
@Autowired
private IRoutesService routesService;
@Autowired
private IObjectOperationLogService objectOperationLogService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
@Qualifier("fourTRedisTemplate")
private RedisTemplate<String, String> fourTRedisTemplate;
/**
* 保存平台样式到 Redis
* 独立平台routeId=0若传 platformIconInstanceId 则按实例存储每个拖上去的图标一套样式否则按 platformId 存储兼容旧逻辑
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping("/savePlatformStyle")
public AjaxResult savePlatformStyle(@RequestBody PlatformStyleDTO dto)
{
if (dto.getRoomId() == null || dto.getRouteId() == null) {
return AjaxResult.error("参数不完整");
}
Long routeId = dto.getRouteId();
boolean isPlatformIcon = routeId != null && routeId == 0L;
if (isPlatformIcon) {
if (dto.getPlatformIconInstanceId() == null && dto.getPlatformId() == null) {
return AjaxResult.error("独立平台样式需传 platformIconInstanceId 或 platformId");
}
} else {
if (dto.getPlatformId() == null) {
return AjaxResult.error("参数不完整");
}
}
String key = isPlatformIcon
? ("room:" + dto.getRoomId() + ":platformIcons:platforms")
: ("room:" + dto.getRoomId() + ":route:" + routeId + ":platforms");
String hashField = isPlatformIcon && dto.getPlatformIconInstanceId() != null
? String.valueOf(dto.getPlatformIconInstanceId())
: String.valueOf(dto.getPlatformId());
redisTemplate.opsForHash().put(key, hashField, JSON.toJSONString(dto));
if (isPlatformIcon && dto.getPlatformIconInstanceId() != null) {
String oldKey = "room:" + dto.getRoomId() + ":route:0:platforms";
redisTemplate.opsForHash().delete(oldKey, hashField);
} else if (isPlatformIcon) {
String oldKey = "room:" + dto.getRoomId() + ":route:0:platforms";
redisTemplate.opsForHash().delete(oldKey, String.valueOf(dto.getPlatformId()));
}
return success();
}
/**
* Redis 获取平台样式
* 独立平台routeId=0优先用 platformIconInstanceId 取该实例样式未传时用 platformId兼容旧数据
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/getPlatformStyle")
public AjaxResult getPlatformStyle(PlatformStyleDTO dto)
{
if (dto.getRoomId() == null || dto.getRouteId() == null) {
return AjaxResult.error("参数不完整");
}
Long routeId = dto.getRouteId();
boolean isPlatformIcon = routeId != null && routeId == 0L;
if (isPlatformIcon) {
if (dto.getPlatformIconInstanceId() == null && dto.getPlatformId() == null) {
return AjaxResult.error("独立平台样式需传 platformIconInstanceId 或 platformId");
}
} else {
if (dto.getPlatformId() == null) {
return AjaxResult.error("参数不完整");
}
}
String key = isPlatformIcon
? ("room:" + dto.getRoomId() + ":platformIcons:platforms")
: ("room:" + dto.getRoomId() + ":route:" + routeId + ":platforms");
String hashField = isPlatformIcon && dto.getPlatformIconInstanceId() != null
? String.valueOf(dto.getPlatformIconInstanceId())
: String.valueOf(dto.getPlatformId());
Object val = redisTemplate.opsForHash().get(key, hashField);
if (val == null && isPlatformIcon) {
String oldKey = "room:" + dto.getRoomId() + ":route:0:platforms";
val = redisTemplate.opsForHash().get(oldKey, hashField);
}
if (val != null) return success(JSON.parseObject(val.toString(), PlatformStyleDTO.class));
return success();
}
/**
* 删除独立平台图标样式按实例删除避免删除图标后仍残留威力区/探测区
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@DeleteMapping("/platformIconStyle")
public AjaxResult deletePlatformIconStyle(@RequestParam Long roomId, @RequestParam Long platformIconInstanceId)
{
if (roomId == null || platformIconInstanceId == null) {
return AjaxResult.error("roomId 与 platformIconInstanceId 不能为空");
}
String key = "room:" + roomId + ":platformIcons:platforms";
redisTemplate.opsForHash().delete(key, String.valueOf(platformIconInstanceId));
String oldKey = "room:" + roomId + ":route:0:platforms";
redisTemplate.opsForHash().delete(oldKey, String.valueOf(platformIconInstanceId));
return success();
}
/**
* 保存4T数据到 Redis按房间存储
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping("/save4TData")
public AjaxResult save4TData(@RequestBody java.util.Map<String, Object> params)
{
Object roomId = params.get("roomId");
Object data = params.get("data");
if (roomId == null || data == null) {
return AjaxResult.error("参数不完整");
}
String key = "room:" + String.valueOf(roomId) + ":4t";
fourTRedisTemplate.opsForValue().set(key, data.toString());
return success();
}
/**
* Redis 获取4T数据
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/get4TData")
public AjaxResult get4TData(Long roomId)
{
if (roomId == null) {
return AjaxResult.error("房间ID不能为空");
}
String key = "room:" + String.valueOf(roomId) + ":4t";
String val = fourTRedisTemplate.opsForValue().get(key);
if (val != null && !val.isEmpty()) {
try {
return success(JSON.parseObject(val));
} catch (Exception e) {
return success(val);
}
}
return success();
}
/**
* Redis 获取截图展示数据GET + roomId 查询参数
* 与保存共用路径前缀方法不同避免与其它 GET 字面路径网关规则混淆
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/roomScreenshotGallery")
public AjaxResult getScreenshotGalleryData(@RequestParam Long roomId)
{
if (roomId == null) {
return AjaxResult.error("房间ID不能为空");
}
String key = "room:" + String.valueOf(roomId) + ":screenshot_gallery";
String val = fourTRedisTemplate.opsForValue().get(key);
if (val != null && !val.isEmpty()) {
try {
return success(JSON.parseObject(val));
} catch (Exception e) {
return success(val);
}
}
return success();
}
/**
* 保存截图展示悬浮窗数据到 RedisPOST 与上面 GET 同一路径
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping("/roomScreenshotGallery")
public AjaxResult saveScreenshotGalleryData(@RequestBody java.util.Map<String, Object> params)
{
Object roomId = params.get("roomId");
Object data = params.get("data");
if (roomId == null || data == null) {
return AjaxResult.error("参数不完整");
}
String key = "room:" + String.valueOf(roomId) + ":screenshot_gallery";
fourTRedisTemplate.opsForValue().set(key, data.toString());
return success();
}
/**
* 保存六步法任务页数据到 Redis背景图标文本框
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping("/saveTaskPageData")
public AjaxResult saveTaskPageData(@RequestBody java.util.Map<String, Object> params)
{
Object roomId = params.get("roomId");
Object data = params.get("data");
if (roomId == null || data == null) {
return AjaxResult.error("参数不完整");
}
String key = "room:" + String.valueOf(roomId) + ":task_page";
fourTRedisTemplate.opsForValue().set(key, data.toString());
return success();
}
/**
* Redis 获取六步法任务页数据
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/getTaskPageData")
public AjaxResult getTaskPageData(Long roomId)
{
if (roomId == null) {
return AjaxResult.error("房间ID不能为空");
}
String key = "room:" + String.valueOf(roomId) + ":task_page";
String val = fourTRedisTemplate.opsForValue().get(key);
if (val != null && !val.isEmpty()) {
try {
return success(JSON.parseObject(val));
} catch (Exception e) {
return success(val);
}
}
return success();
}
/**
* 保存六步法全部数据到 Redis任务页理解后五步背景多页等
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping("/saveSixStepsData")
public AjaxResult saveSixStepsData(@RequestBody java.util.Map<String, Object> params)
{
Object roomId = params.get("roomId");
Object data = params.get("data");
if (roomId == null || data == null) {
return AjaxResult.error("参数不完整");
}
String key = "room:" + String.valueOf(roomId) + ":six_steps";
fourTRedisTemplate.opsForValue().set(key, data.toString());
return success();
}
/**
* Redis 获取六步法全部数据
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/getSixStepsData")
public AjaxResult getSixStepsData(Long roomId)
{
if (roomId == null) {
return AjaxResult.error("房间ID不能为空");
}
String key = "room:" + String.valueOf(roomId) + ":six_steps";
String val = fourTRedisTemplate.opsForValue().get(key);
if (val != null && !val.isEmpty()) {
try {
return success(JSON.parseObject(val));
} catch (Exception e) {
return success(val);
}
}
return success();
}
/**
* 获取导弹发射参数列表Redis房间+航线+平台为 key值为数组每项含 angle/distance/launchTimeMinutesFromK/startLng/startLat/platformHeadingDeg
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping("/missile-params")
public AjaxResult getMissileParams(Long roomId, Long routeId, Long platformId)
{
if (roomId == null || routeId == null || platformId == null) {
return AjaxResult.error("参数不完整");
}
String key = "missile:params:" + roomId + ":" + routeId + ":" + platformId;
Object val = redisTemplate.opsForValue().get(key);
if (val == null) {
return success(new ArrayList<>());
}
String raw = val.toString();
if (raw.startsWith("[")) {
return success(JSON.parseArray(raw));
}
JSONObject single = JSON.parseObject(raw);
List<JSONObject> list = new ArrayList<>();
list.add(single);
return success(list);
}
/**
* 保存导弹发射参数到 Redis追加一条记录支持多次发射每条含 angle/distance/launchTimeMinutesFromK/startLng/startLat/platformHeadingDeg
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PostMapping("/missile-params")
public AjaxResult saveMissileParams(@RequestBody java.util.Map<String, Object> body)
{
Object roomId = body.get("roomId");
Object routeId = body.get("routeId");
Object platformId = body.get("platformId");
if (roomId == null || routeId == null || platformId == null) {
return AjaxResult.error("参数不完整");
}
String key = "missile:params:" + roomId + ":" + routeId + ":" + platformId;
Object val = redisTemplate.opsForValue().get(key);
JSONArray list = new JSONArray();
if (val != null) {
String raw = val.toString();
if (raw.startsWith("[")) {
list = JSON.parseArray(raw);
} else {
list.add(JSON.parseObject(raw));
}
}
list.add(body);
redisTemplate.opsForValue().set(key, list.toJSONString());
return success();
}
/**
* 删除指定导弹发射参数按数组索引删除避免按 launchTimeMinutesFromK 匹配时浮点误差导致删错
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@DeleteMapping("/missile-params")
public AjaxResult deleteMissileParams(
@RequestParam Long roomId,
@RequestParam Long routeId,
@RequestParam Long platformId,
@RequestParam Integer index)
{
if (roomId == null || routeId == null || platformId == null || index == null) {
return AjaxResult.error("参数不完整");
}
String key = "missile:params:" + roomId + ":" + routeId + ":" + platformId;
Object val = redisTemplate.opsForValue().get(key);
if (val == null) {
return success();
}
String raw = val.toString();
JSONArray list;
if (raw.startsWith("[")) {
list = JSON.parseArray(raw);
} else {
list = new JSONArray();
list.add(JSON.parseObject(raw));
}
int idx = index.intValue();
if (idx < 0 || idx >= list.size()) {
return AjaxResult.error("索引越界");
}
JSONArray filtered = new JSONArray();
for (int i = 0; i < list.size(); i++) {
if (i != idx) {
filtered.add(list.get(i));
}
}
if (filtered.isEmpty()) {
redisTemplate.delete(key);
} else {
redisTemplate.opsForValue().set(key, filtered.toJSONString());
}
return success();
}
/**
* 查询实体部署与航线列表
*/
@ -61,9 +435,10 @@ public class RoutesController extends BaseController
/**
* 获取实体部署与航线详细信息
* 路径仅匹配数字 id避免与 /get4TData/getScreenshotGalleryData 等字面路径冲突
*/
@PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping(value = "/{id}")
@GetMapping(value = "/{id:\\d+}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
return success(routesService.selectRoutesById(id));
@ -71,39 +446,171 @@ public class RoutesController extends BaseController
/**
* 新增实体部署与航线
* @param roomId 可选房间ID时记录到对象级操作日志便于按房间分页与回滚
*/
@PreAuthorize("@ss.hasPermi('system:routes:add')")
@Log(title = "实体部署与航线", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody Routes routes)
public AjaxResult add(@RequestBody Routes routes, @RequestParam(required = false) Long roomId)
{
// 1. 执行插入,MyBatis 会通过 useGeneratedKeys="true" 自动将新 ID 注入 routes 对象
int rows = routesService.insertRoutes(routes);
// 2. 不要用 toAjax,直接返回 success 并带上 routes 对象
// 这样前端 response.data 就会包含这个带有 ID 的完整对象
if (rows > 0) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_INSERT);
opLog.setObjectType(ObjectOperationLog.OBJ_ROUTE);
opLog.setObjectId(String.valueOf(routes.getId()));
opLog.setObjectName(routes.getCallSign() != null ? routes.getCallSign() : "航线");
opLog.setDetail("新增航线:" + (routes.getCallSign() != null ? routes.getCallSign() : routes.getId()));
opLog.setSnapshotAfter(JSON.toJSONString(routes));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
return rows > 0 ? AjaxResult.success(routes) : AjaxResult.error("新增航线失败");
}
/**
* 修改实体部署与航线
* @param roomId 可选房间ID时记录到对象级操作日志
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@Log(title = "实体部署与航线", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody Routes routes)
public AjaxResult edit(@RequestBody Routes routes, @RequestParam(required = false) Long roomId)
{
return toAjax(routesService.updateRoutes(routes));
Routes before = null;
if (routes.getId() != null) {
before = routesService.selectRoutesById(routes.getId());
}
int rows = routesService.updateRoutes(routes);
if (rows > 0 && before != null) {
try {
// 操作后快照用“更新后重新查库”的完整数据,避免请求体缺字段导致日志里误显示为“已清空”
Routes after = routesService.selectRoutesById(routes.getId());
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_UPDATE);
opLog.setObjectType(ObjectOperationLog.OBJ_ROUTE);
opLog.setObjectId(String.valueOf(routes.getId()));
opLog.setObjectName(routes.getCallSign() != null ? routes.getCallSign() : "航线");
opLog.setDetail("修改航线:" + (routes.getCallSign() != null ? routes.getCallSign() : routes.getId()));
opLog.setSnapshotBefore(JSON.toJSONString(before));
opLog.setSnapshotAfter(after != null ? JSON.toJSONString(after) : JSON.toJSONString(routes));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
return toAjax(rows);
}
/**
* 删除实体部署与航线
* 删除实体部署与航线同时清除该航线在所有房间下的 Redis 导弹数据
* @param roomId 可选房间ID时记录到对象级操作日志
*/
@PreAuthorize("@ss.hasPermi('system:routes:remove')")
@Log(title = "实体部署与航线", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
public AjaxResult remove(@PathVariable Long[] ids, @RequestParam(required = false) Long roomId)
{
for (Long id : ids) {
Routes before = routesService.selectRoutesById(id);
if (before != null) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_DELETE);
opLog.setObjectType(ObjectOperationLog.OBJ_ROUTE);
opLog.setObjectId(String.valueOf(id));
opLog.setObjectName(before.getCallSign() != null ? before.getCallSign() : "航线");
opLog.setDetail("删除航线:" + (before.getCallSign() != null ? before.getCallSign() : id));
opLog.setSnapshotBefore(JSON.toJSONString(before));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录操作日志失败", e);
}
}
}
int rows = routesService.deleteRoutesByIds(ids);
if (rows > 0) {
for (Long routeId : ids) {
// 清除该航线在所有房间下的导弹参数
Set<String> keys = redisTemplate.keys("missile:params:*:" + routeId + ":*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
// 同步删除该航线在所有房间下的平台样式(探测区/威力区等),避免库表已删但 Redis 仍残留
Set<String> styleKeys = redisTemplate.keys("room:*:route:" + routeId + ":platforms");
if (styleKeys != null && !styleKeys.isEmpty()) {
redisTemplate.delete(styleKeys);
}
}
}
return toAjax(rows);
}
/**
* 批量更新导弹发射位置航线编辑后根据新航点重算平台位置并更新 Redis 中的 startLng/startLat/platformHeadingDeg
* body: { roomId, routeId, platformId, updates: [{ launchTimeMinutesFromK, startLng, startLat, platformHeadingDeg }, ...] }
*/
@PreAuthorize("@ss.hasPermi('system:routes:edit')")
@PutMapping("/missile-params/positions")
public AjaxResult updateMissilePositions(@RequestBody java.util.Map<String, Object> body)
{
return toAjax(routesService.deleteRoutesByIds(ids));
Object roomId = body.get("roomId");
Object routeId = body.get("routeId");
Object platformId = body.get("platformId");
Object updatesObj = body.get("updates");
if (roomId == null || routeId == null || platformId == null || updatesObj == null) {
return AjaxResult.error("参数不完整");
}
if (!(updatesObj instanceof java.util.List)) {
return AjaxResult.error("updates 必须为数组");
}
java.util.List<?> updates = (java.util.List<?>) updatesObj;
if (updates.isEmpty()) return success();
String key = "missile:params:" + roomId + ":" + routeId + ":" + platformId;
Object val = redisTemplate.opsForValue().get(key);
if (val == null) return success();
String raw = val.toString();
JSONArray list;
if (raw.startsWith("[")) {
list = JSON.parseArray(raw);
} else {
list = new JSONArray();
list.add(JSON.parseObject(raw));
}
for (int i = 0; i < list.size(); i++) {
JSONObject item = list.getJSONObject(i);
Double k = item.getDouble("launchTimeMinutesFromK");
if (k == null) continue;
for (Object u : updates) {
if (!(u instanceof java.util.Map)) continue;
java.util.Map<?, ?> uMap = (java.util.Map<?, ?>) u;
Object uk = uMap.get("launchTimeMinutesFromK");
if (uk == null) continue;
double ukVal = uk instanceof Number ? ((Number) uk).doubleValue() : Double.parseDouble(uk.toString());
if (Math.abs(k - ukVal) < 1e-6) {
if (uMap.containsKey("startLng")) item.put("startLng", uMap.get("startLng"));
if (uMap.containsKey("startLat")) item.put("startLat", uMap.get("startLat"));
if (uMap.containsKey("platformHeadingDeg")) item.put("platformHeadingDeg", uMap.get("platformHeadingDeg"));
break;
}
}
}
redisTemplate.opsForValue().set(key, list.toJSONString());
return success();
}
}

163
ruoyi-admin/src/main/java/com/ruoyi/web/controller/WhiteboardController.java

@ -0,0 +1,163 @@
package com.ruoyi.web.controller;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.file.FileUploadUtils;
import com.ruoyi.common.utils.file.FileUtils;
import com.ruoyi.common.utils.file.MimeTypeUtils;
import com.ruoyi.framework.config.ServerConfig;
import com.ruoyi.system.domain.RoomUserProfile;
import com.ruoyi.system.service.IRoomUserProfileService;
import com.ruoyi.websocket.service.WhiteboardRoomService;
/**
* 白板 Controller房间维度的白板 CRUD数据存 Redis
*/
@RestController
@RequestMapping("/room")
public class WhiteboardController extends BaseController {
@Autowired
private WhiteboardRoomService whiteboardRoomService;
@Autowired
private IRoomUserProfileService roomUserProfileService;
@Autowired
private ServerConfig serverConfig;
/** 获取房间下所有白板列表 */
@GetMapping("/{roomId}/whiteboards")
public AjaxResult list(@PathVariable Long roomId) {
List<Object> list = whiteboardRoomService.listWhiteboards(roomId);
return success(list);
}
/** 获取单个白板详情 */
@GetMapping("/{roomId}/whiteboard/{whiteboardId}")
public AjaxResult get(@PathVariable Long roomId, @PathVariable String whiteboardId) {
Object wb = whiteboardRoomService.getWhiteboard(roomId, whiteboardId);
if (wb == null) return error("白板不存在");
return success(wb);
}
/** 创建白板 */
@PostMapping("/{roomId}/whiteboard")
public AjaxResult create(@PathVariable Long roomId, @RequestBody Object whiteboard) {
Object created = whiteboardRoomService.createWhiteboard(roomId, whiteboard);
return success(created);
}
/** 更新白板 */
@PutMapping("/{roomId}/whiteboard/{whiteboardId}")
public AjaxResult update(@PathVariable Long roomId, @PathVariable String whiteboardId,
@RequestBody Object whiteboard) {
boolean ok = whiteboardRoomService.updateWhiteboard(roomId, whiteboardId, whiteboard);
return ok ? success() : error("更新失败");
}
/** 删除白板 */
@DeleteMapping("/{roomId}/whiteboard/{whiteboardId}")
public AjaxResult delete(@PathVariable Long roomId, @PathVariable String whiteboardId) {
boolean ok = whiteboardRoomService.deleteWhiteboard(roomId, whiteboardId);
return ok ? success() : error("删除失败");
}
/** 保存白板平台样式(Redis Key: whiteboard:scheme:{schemeId}:platform:{platformInstanceId}:style) */
@PostMapping("/whiteboard/platform/style")
public AjaxResult savePlatformStyle(@RequestBody java.util.Map<String, Object> body) {
if (body == null) return error("参数不能为空");
String schemeId = body.get("schemeId") == null ? null : String.valueOf(body.get("schemeId"));
String platformInstanceId = body.get("platformInstanceId") == null ? null : String.valueOf(body.get("platformInstanceId"));
Object style = body.get("style");
if (schemeId == null || schemeId.trim().isEmpty() || platformInstanceId == null || platformInstanceId.trim().isEmpty() || style == null) {
return error("schemeId、platformInstanceId、style 不能为空");
}
whiteboardRoomService.saveWhiteboardPlatformStyle(schemeId, platformInstanceId, style);
return success();
}
/** 获取白板平台样式 */
@GetMapping("/whiteboard/platform/style")
public AjaxResult getPlatformStyle(@RequestParam String schemeId, @RequestParam String platformInstanceId) {
Object style = whiteboardRoomService.getWhiteboardPlatformStyle(schemeId, platformInstanceId);
return success(style);
}
/** 删除白板平台样式 */
@DeleteMapping("/whiteboard/platform/style")
public AjaxResult deletePlatformStyle(@RequestParam String schemeId, @RequestParam String platformInstanceId) {
whiteboardRoomService.deleteWhiteboardPlatformStyle(schemeId, platformInstanceId);
return success();
}
@GetMapping("/user/profile")
public AjaxResult getCurrentUserProfile() {
LoginUser loginUser = getLoginUser();
RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername());
return success(buildProfilePayload(loginUser, profile));
}
@PutMapping("/user/profile")
public AjaxResult updateCurrentUserProfile(@RequestBody Map<String, Object> body) {
LoginUser loginUser = getLoginUser();
String displayName = body != null && body.get("displayName") != null ? String.valueOf(body.get("displayName")).trim() : null;
if (StringUtils.isEmpty(displayName)) {
return error("用户名不能为空");
}
if (displayName.length() > 32) {
return error("用户名长度不能超过32个字符");
}
RoomUserProfile profile = roomUserProfileService.updateDisplayName(loginUser.getUserId(), displayName, loginUser.getUsername());
return success(buildProfilePayload(loginUser, profile));
}
@PostMapping("/user/profile/avatar")
public AjaxResult uploadCurrentUserAvatar(@RequestParam("avatarfile") MultipartFile file) throws Exception {
if (file == null || file.isEmpty()) {
return error("请选择头像图片");
}
LoginUser loginUser = getLoginUser();
RoomUserProfile oldProfile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername());
String oldAvatar = oldProfile != null ? oldProfile.getAvatar() : null;
String avatar = FileUploadUtils.upload(RuoYiConfig.getRoomUserAvatarPath(), file, MimeTypeUtils.IMAGE_EXTENSION, true);
RoomUserProfile profile = roomUserProfileService.updateAvatar(loginUser.getUserId(), avatar, loginUser.getUsername());
// 仅删除本模块上传目录中的旧头像,避免误删系统头像或外链头像
if (StringUtils.isNotEmpty(oldAvatar) && oldAvatar.startsWith("/room-user-avatar/")) {
FileUtils.deleteFile(RuoYiConfig.getProfile() + FileUtils.stripPrefix(oldAvatar));
}
AjaxResult ajax = success(buildProfilePayload(loginUser, profile));
ajax.put("imgUrl", avatar);
ajax.put("fullImgUrl", avatar.startsWith("http") ? avatar : (serverConfig.getUrl() + avatar));
return ajax;
}
private Map<String, Object> buildProfilePayload(LoginUser loginUser, RoomUserProfile profile) {
Map<String, Object> payload = new HashMap<>();
payload.put("userId", loginUser.getUserId());
payload.put("userName", loginUser.getUsername());
payload.put("displayName", profile != null ? profile.getDisplayName() : loginUser.getUsername());
payload.put("avatar", profile != null ? profile.getAvatar() : null);
return payload;
}
}

2
ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java

@ -9,6 +9,7 @@ import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
@ -32,6 +33,7 @@ import com.ruoyi.system.domain.SysCache;
public class CacheController
{
@Autowired
@Qualifier("stringRedisTemplate")
private RedisTemplate<String, String> redisTemplate;
private final static List<SysCache> caches = new ArrayList<SysCache>();

81
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysTimelineSegmentController.java

@ -0,0 +1,81 @@
package com.ruoyi.web.controller.system;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysTimelineSegment;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.service.ISysTimelineSegmentService;
@RestController
@RequestMapping("/system/timeline")
public class SysTimelineSegmentController extends BaseController
{
@Autowired
private ISysTimelineSegmentService segmentService;
@PreAuthorize("@ss.hasPermi('system:timeline:list')")
@GetMapping("/list")
public AjaxResult list(SysTimelineSegment segment)
{
List<SysTimelineSegment> list = segmentService.selectSegmentList(segment);
return success(list);
}
@GetMapping("/listByRoomId/{roomId}")
public AjaxResult listByRoomId(@PathVariable Long roomId)
{
List<SysTimelineSegment> list = segmentService.selectSegmentListByRoomId(roomId);
return success(list);
}
@PreAuthorize("@ss.hasPermi('system:timeline:query')")
@GetMapping(value = "/{segmentId}")
public AjaxResult getInfo(@PathVariable Long segmentId)
{
return success(segmentService.selectSegmentById(segmentId));
}
@PreAuthorize("@ss.hasPermi('system:timeline:add')")
@Log(title = "时间轴管理", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody SysTimelineSegment segment)
{
return toAjax(segmentService.insertSegment(segment));
}
@PreAuthorize("@ss.hasPermi('system:timeline:edit')")
@Log(title = "时间轴管理", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody SysTimelineSegment segment)
{
return toAjax(segmentService.updateSegment(segment));
}
@PreAuthorize("@ss.hasPermi('system:timeline:remove')")
@Log(title = "时间轴管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{segmentId}")
public AjaxResult remove(@PathVariable Long segmentId)
{
return toAjax(segmentService.deleteSegmentById(segmentId));
}
@PreAuthorize("@ss.hasPermi('system:timeline:remove')")
@Log(title = "时间轴管理", businessType = BusinessType.DELETE)
@DeleteMapping("/room/{roomId}")
public AjaxResult removeByRoomId(@PathVariable Long roomId)
{
return toAjax(segmentService.deleteSegmentByRoomId(roomId));
}
}

27
ruoyi-admin/src/main/java/com/ruoyi/websocket/config/LoginUserPrincipal.java

@ -0,0 +1,27 @@
package com.ruoyi.websocket.config;
import java.security.Principal;
import com.ruoyi.common.core.domain.model.LoginUser;
/**
* LoginUser 包装为 Principal WebSocket 使用
*
* @author ruoyi
*/
public class LoginUserPrincipal implements Principal {
private final LoginUser loginUser;
public LoginUserPrincipal(LoginUser loginUser) {
this.loginUser = loginUser;
}
@Override
public String getName() {
return loginUser != null ? loginUser.getUsername() : null;
}
public LoginUser getLoginUser() {
return loginUser;
}
}

37
ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketChannelInterceptor.java

@ -0,0 +1,37 @@
package com.ruoyi.websocket.config;
import java.util.Map;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.websocket.config.LoginUserPrincipal;
/**
* WebSocket 通道拦截器将握手时的 loginUser 注入到消息头
*
* @author ruoyi
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketChannelInterceptor implements ChannelInterceptor {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
Map<String, Object> sessionAttrs = accessor.getSessionAttributes();
if (sessionAttrs != null && sessionAttrs.containsKey("loginUser")) {
LoginUser loginUser = (LoginUser) sessionAttrs.get("loginUser");
accessor.setUser(new LoginUserPrincipal(loginUser));
}
}
return message;
}
}

47
ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketConfig.java

@ -0,0 +1,47 @@
package com.ruoyi.websocket.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* WebSocket STOMP 配置
*
* @author ruoyi
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Autowired
private WebSocketHandshakeHandler webSocketHandshakeHandler;
@Autowired
private WebSocketChannelInterceptor webSocketChannelInterceptor;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue");
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.addInterceptors(webSocketHandshakeHandler)
.withSockJS();
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketChannelInterceptor);
}
}

80
ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketHandshakeHandler.java

@ -0,0 +1,80 @@
package com.ruoyi.websocket.config;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.web.service.TokenService;
/**
* WebSocket 握手拦截器校验 JWT Token支持 query 参数 token Authorization header
*
* @author ruoyi
*/
@Component
public class WebSocketHandshakeHandler implements HandshakeInterceptor {
private final TokenService tokenService;
public WebSocketHandshakeHandler(TokenService tokenService) {
this.tokenService = tokenService;
}
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
if (!(request instanceof ServletServerHttpRequest)) {
return false;
}
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
HttpServletRequest req = servletRequest.getServletRequest();
String token = req.getParameter("token");
if (StringUtils.isEmpty(token)) {
String auth = req.getHeader("Authorization");
if (StringUtils.isNotEmpty(auth) && auth.startsWith(Constants.TOKEN_PREFIX)) {
token = auth.substring(Constants.TOKEN_PREFIX.length()).trim();
}
}
if (StringUtils.isEmpty(token)) {
return false;
}
final String tokenFinal = token;
HttpServletRequest wrappedReq = new HttpServletRequestWrapper(req) {
@Override
public String getHeader(String name) {
if ("Authorization".equalsIgnoreCase(name)) {
return Constants.TOKEN_PREFIX + tokenFinal;
}
return super.getHeader(name);
}
@Override
public Enumeration<String> getHeaders(String name) {
if ("Authorization".equalsIgnoreCase(name)) {
return Collections.enumeration(Collections.singletonList(Constants.TOKEN_PREFIX + tokenFinal));
}
return super.getHeaders(name);
}
};
LoginUser loginUser = tokenService.getLoginUser(wrappedReq);
if (loginUser == null) {
return false;
}
attributes.put("loginUser", loginUser);
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
}
}

348
ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java

@ -0,0 +1,348 @@
package com.ruoyi.websocket.controller;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.websocket.config.LoginUserPrincipal;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.domain.Rooms;
import com.ruoyi.system.domain.RoomUserProfile;
import com.ruoyi.system.service.IRoomsService;
import com.ruoyi.system.service.IRoomUserProfileService;
import com.ruoyi.websocket.dto.RoomMemberDTO;
import com.ruoyi.websocket.service.RoomChatService;
import com.ruoyi.websocket.service.RoomOnlineMemberBroadcastService;
import com.ruoyi.websocket.service.RoomWebSocketService;
/**
* WebSocket 房间消息控制器
*
* @author ruoyi
*/
@Controller
public class RoomWebSocketController {
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private RoomWebSocketService roomWebSocketService;
@Autowired
private RoomChatService roomChatService;
@Autowired
private IRoomsService roomsService;
@Autowired
private RoomOnlineMemberBroadcastService roomOnlineMemberBroadcastService;
@Autowired
private IRoomUserProfileService roomUserProfileService;
private static final String TYPE_JOIN = "JOIN";
private static final String TYPE_LEAVE = "LEAVE";
private static final String TYPE_PING = "PING";
private static final String TYPE_CHAT = "CHAT";
private static final String TYPE_PRIVATE_CHAT = "PRIVATE_CHAT";
private static final String TYPE_CHAT_HISTORY = "CHAT_HISTORY";
private static final String TYPE_PRIVATE_CHAT_HISTORY = "PRIVATE_CHAT_HISTORY";
private static final String TYPE_PRIVATE_CHAT_HISTORY_REQUEST = "PRIVATE_CHAT_HISTORY_REQUEST";
private static final String TYPE_MEMBER_LEFT = "MEMBER_LEFT";
private static final String TYPE_PONG = "PONG";
private static final String TYPE_SYNC_ROUTE_VISIBILITY = "SYNC_ROUTE_VISIBILITY";
private static final String TYPE_SYNC_WAYPOINTS = "SYNC_WAYPOINTS";
private static final String TYPE_SYNC_PLATFORM_ICONS = "SYNC_PLATFORM_ICONS";
private static final String TYPE_SYNC_ROOM_DRAWINGS = "SYNC_ROOM_DRAWINGS";
private static final String TYPE_SYNC_PLATFORM_STYLES = "SYNC_PLATFORM_STYLES";
/** 新用户加入时下发的房间状态(可见航线等) */
private static final String TYPE_ROOM_STATE = "ROOM_STATE";
/** 对象编辑锁定:某成员进入编辑,其他人看到锁定 */
private static final String TYPE_OBJECT_EDIT_LOCK = "OBJECT_EDIT_LOCK";
/** 对象编辑解锁 */
private static final String TYPE_OBJECT_EDIT_UNLOCK = "OBJECT_EDIT_UNLOCK";
/**
* 处理房间消息JOINLEAVEPINGCHATPRIVATE_CHATSYNC_*
* 处理房间消息JOINLEAVEPINGCHATPRIVATE_CHATOBJECT_VIEWOBJECT_EDIT_LOCK
*/
@MessageMapping("/room/{roomId}")
public void handleRoomMessage(@DestinationVariable Long roomId, @Payload String payload,
SimpMessageHeaderAccessor accessor) {
LoginUser loginUser = null;
if (accessor.getUser() instanceof LoginUserPrincipal) {
loginUser = ((LoginUserPrincipal) accessor.getUser()).getLoginUser();
}
if (loginUser == null) return;
Map<String, Object> body = parsePayload(payload);
if (body == null) return;
String type = (String) body.get("type");
String sessionId = accessor.getSessionId();
if (sessionId == null) sessionId = UUID.randomUUID().toString();
if (TYPE_JOIN.equals(type)) {
handleJoin(roomId, sessionId, loginUser, body);
} else if (TYPE_LEAVE.equals(type)) {
handleLeave(roomId, sessionId, loginUser);
} else if (TYPE_PING.equals(type)) {
handlePing(roomId, sessionId, loginUser);
} else if (TYPE_CHAT.equals(type)) {
handleChat(roomId, sessionId, loginUser, body);
} else if (TYPE_PRIVATE_CHAT.equals(type)) {
handlePrivateChat(roomId, sessionId, loginUser, body);
} else if (TYPE_PRIVATE_CHAT_HISTORY_REQUEST.equals(type)) {
handlePrivateChatHistoryRequest(roomId, loginUser, body);
} else if (TYPE_SYNC_ROUTE_VISIBILITY.equals(type)) {
handleSyncRouteVisibility(roomId, body, sessionId);
} else if (TYPE_SYNC_WAYPOINTS.equals(type)) {
handleSyncWaypoints(roomId, body, sessionId);
} else if (TYPE_SYNC_PLATFORM_ICONS.equals(type)) {
handleSyncPlatformIcons(roomId, body, sessionId);
} else if (TYPE_SYNC_ROOM_DRAWINGS.equals(type)) {
handleSyncRoomDrawings(roomId, body, sessionId);
} else if (TYPE_SYNC_PLATFORM_STYLES.equals(type)) {
handleSyncPlatformStyles(roomId, body, sessionId);
} else if (TYPE_OBJECT_EDIT_LOCK.equals(type)) {
handleObjectEditLock(roomId, sessionId, loginUser, body);
} else if (TYPE_OBJECT_EDIT_UNLOCK.equals(type)) {
handleObjectEditUnlock(roomId, sessionId, loginUser, body);
}
}
/** 广播:某成员锁定某对象进入编辑 */
private void handleObjectEditLock(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) {
String objectType = body != null ? String.valueOf(body.get("objectType")) : null;
Object objectIdObj = body != null ? body.get("objectId") : null;
if (objectType == null || objectIdObj == null) return;
RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername());
Map<String, Object> editor = new HashMap<>();
editor.put("userId", loginUser.getUserId());
editor.put("userName", loginUser.getUsername());
editor.put("nickName", profile != null ? profile.getDisplayName() : loginUser.getUser().getNickName());
editor.put("sessionId", sessionId);
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_OBJECT_EDIT_LOCK);
msg.put("objectType", objectType);
msg.put("objectId", objectIdObj);
msg.put("editor", editor);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 广播:某成员解锁某对象(结束编辑) */
private void handleObjectEditUnlock(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) {
String objectType = body != null ? String.valueOf(body.get("objectType")) : null;
Object objectIdObj = body != null ? body.get("objectId") : null;
if (objectType == null || objectIdObj == null) return;
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_OBJECT_EDIT_UNLOCK);
msg.put("objectType", objectType);
msg.put("objectId", objectIdObj);
msg.put("sessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
@SuppressWarnings("unchecked")
private Map<String, Object> parsePayload(String payload) {
try {
return JSON.parseObject(payload, Map.class);
} catch (Exception e) {
return null;
}
}
private void handleJoin(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) {
Rooms room = roomsService.selectRoomsById(roomId);
if (room == null) return;
RoomMemberDTO member = buildMember(loginUser, sessionId, roomId, body);
roomWebSocketService.joinRoom(roomId, sessionId, member);
roomOnlineMemberBroadcastService.broadcastAfterMembershipChange(roomId);
List<Object> chatHistory = roomChatService.getGroupChatHistory(roomId);
Map<String, Object> chatHistoryMsg = new HashMap<>();
chatHistoryMsg.put("type", TYPE_CHAT_HISTORY);
chatHistoryMsg.put("messages", chatHistory);
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", chatHistoryMsg);
// 小房间内每人展示各自内容,新加入用户不同步他人的可见航线
}
private void handleLeave(Long roomId, String sessionId, LoginUser loginUser) {
RoomMemberDTO member = buildMember(loginUser, sessionId, roomId, null);
roomWebSocketService.leaveRoom(roomId, sessionId, loginUser.getUserId());
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_MEMBER_LEFT);
msg.put("member", member);
msg.put("sessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
roomOnlineMemberBroadcastService.broadcastAfterMembershipChange(roomId);
}
private void handlePing(Long roomId, String sessionId, LoginUser loginUser) {
roomWebSocketService.refreshSessionHeartbeat(sessionId);
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_PONG);
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", msg);
}
/** 群聊:广播给房间内所有人 */
private void handleChat(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) {
String content = body != null && body.containsKey("content") ? String.valueOf(body.get("content")) : "";
if (content.isEmpty()) return;
RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername());
Map<String, Object> sender = new HashMap<>();
sender.put("userId", loginUser.getUserId());
sender.put("userName", loginUser.getUsername());
sender.put("nickName", profile != null ? profile.getDisplayName() : loginUser.getUser().getNickName());
sender.put("avatar", profile != null ? profile.getAvatar() : loginUser.getUser().getAvatar());
sender.put("sessionId", sessionId);
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_CHAT);
msg.put("sender", sender);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis());
roomChatService.saveGroupChat(roomId, loginUser.getUserId(), msg);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 私聊:发送给指定用户(仅限同房间成员) */
private void handlePrivateChat(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) {
Object targetUserNameObj = body != null ? body.get("targetUserName") : null;
Object targetUserIdObj = body != null ? body.get("targetUserId") : null;
String content = body != null && body.containsKey("content") ? String.valueOf(body.get("content")) : "";
if (targetUserNameObj == null || content.isEmpty()) return;
String targetUserName = String.valueOf(targetUserNameObj);
if (targetUserName.equals(loginUser.getUsername())) return;
List<RoomMemberDTO> members = roomOnlineMemberBroadcastService.aggregateMembersForView(roomId);
boolean targetInRoom = members.stream().anyMatch(m -> targetUserName.equals(m.getUserName()));
if (!targetInRoom) return;
RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername());
Map<String, Object> sender = new HashMap<>();
sender.put("userId", loginUser.getUserId());
sender.put("userName", loginUser.getUsername());
sender.put("nickName", profile != null ? profile.getDisplayName() : loginUser.getUser().getNickName());
sender.put("avatar", profile != null ? profile.getAvatar() : loginUser.getUser().getAvatar());
sender.put("sessionId", sessionId);
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_PRIVATE_CHAT);
msg.put("sender", sender);
msg.put("targetUserId", targetUserIdObj);
msg.put("targetUserName", targetUserName);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis());
Long targetUserId = targetUserIdObj instanceof Number ? ((Number) targetUserIdObj).longValue() : null;
if (targetUserId != null) {
roomChatService.savePrivateChat(loginUser.getUserId(), targetUserId, msg);
}
messagingTemplate.convertAndSendToUser(targetUserName, "/queue/private", msg);
}
/** 私聊历史请求:返回与指定用户的聊天记录 */
private void handlePrivateChatHistoryRequest(Long roomId, LoginUser loginUser, Map<String, Object> body) {
Object targetUserIdObj = body != null ? body.get("targetUserId") : null;
if (targetUserIdObj == null) return;
Long targetUserId = targetUserIdObj instanceof Number ? ((Number) targetUserIdObj).longValue() : null;
if (targetUserId == null) return;
List<RoomMemberDTO> members = roomOnlineMemberBroadcastService.aggregateMembersForView(roomId);
boolean targetInRoom = members.stream().anyMatch(m -> targetUserId.equals(m.getUserId()));
if (!targetInRoom) return;
List<Object> history = roomChatService.getPrivateChatHistory(loginUser.getUserId(), targetUserId);
Map<String, Object> resp = new HashMap<>();
resp.put("type", TYPE_PRIVATE_CHAT_HISTORY);
resp.put("targetUserId", targetUserId);
resp.put("messages", history);
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", resp);
}
/** 航线显隐变更:小房间内每人展示各自内容,不再广播和持久化 */
private void handleSyncRouteVisibility(Long roomId, Map<String, Object> body, String sessionId) {
// 小房间内每人展示各自内容,航线显隐不再同步给他人、不再持久化
}
/** 广播航点变更,供其他设备实时同步 */
private void handleSyncWaypoints(Long roomId, Map<String, Object> body, String sessionId) {
if (body == null || !body.containsKey("routeId")) return;
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_SYNC_WAYPOINTS);
msg.put("routeId", body.get("routeId"));
msg.put("senderSessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 广播平台图标变更,供其他设备实时同步 */
private void handleSyncPlatformIcons(Long roomId, Map<String, Object> body, String sessionId) {
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_SYNC_PLATFORM_ICONS);
msg.put("senderSessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 广播空域图形变更,供其他设备实时同步 */
private void handleSyncRoomDrawings(Long roomId, Map<String, Object> body, String sessionId) {
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_SYNC_ROOM_DRAWINGS);
msg.put("senderSessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 广播探测区/威力区样式变更,供其他设备实时同步 */
private void handleSyncPlatformStyles(Long roomId, Map<String, Object> body, String sessionId) {
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_SYNC_PLATFORM_STYLES);
msg.put("senderSessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
private RoomMemberDTO buildMember(LoginUser loginUser, String sessionId, Long roomId, Map<String, Object> body) {
RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername());
RoomMemberDTO dto = new RoomMemberDTO();
dto.setUserId(loginUser.getUserId());
dto.setUserName(loginUser.getUsername());
dto.setNickName(profile != null ? profile.getDisplayName() : loginUser.getUser().getNickName());
dto.setAvatar(profile != null ? profile.getAvatar() : loginUser.getUser().getAvatar());
dto.setRoomId(roomId);
dto.setSessionId(sessionId);
dto.setDeviceId(body != null && body.containsKey("deviceId") ? String.valueOf(body.get("deviceId")) : "default");
dto.setJoinedAt(System.currentTimeMillis());
Rooms room = roomsService.selectRoomsById(roomId);
if (room != null && loginUser.getUserId().equals(room.getOwnerId())) {
dto.setRole("owner");
} else if (StringUtils.isNotEmpty(loginUser.getUser().getUserLevel())) {
dto.setRole(loginUser.getUser().getUserLevel());
} else {
dto.setRole("member");
}
return dto;
}
}

106
ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomMemberDTO.java

@ -0,0 +1,106 @@
package com.ruoyi.websocket.dto;
import java.io.Serializable;
/**
* WebSocket 房间成员信息 DTO仅用于 Redis 序列化非数据库实体
*
* @author ruoyi
*/
public class RoomMemberDTO implements Serializable {
private static final long serialVersionUID = 1L;
/** 用户ID */
private Long userId;
/**
* 该成员会话所属的房间ID用于前端按当前房间统计在线人数
* 大房间聚合下发给子房间时仍保留成员的原始房间来源
*/
private Long roomId;
/** 用户账号 */
private String userName;
/** 用户昵称 */
private String nickName;
/** 头像地址 */
private String avatar;
/** WebSocket 会话ID */
private String sessionId;
/** 设备标识 */
private String deviceId;
/** 加入时间戳 */
private Long joinedAt;
/** 角色:owner=房主, admin=管理员, member=成员 */
private String role;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getRoomId() {
return roomId;
}
public void setRoomId(Long roomId) {
this.roomId = roomId;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getNickName() {
return nickName;
}
public void setNickName(String nickName) {
this.nickName = nickName;
}
public String getAvatar() {
return avatar;
}
public void setAvatar(String avatar) {
this.avatar = avatar;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
public String getDeviceId() {
return deviceId;
}
public void setDeviceId(String deviceId) {
this.deviceId = deviceId;
}
public Long getJoinedAt() {
return joinedAt;
}
public void setJoinedAt(Long joinedAt) {
this.joinedAt = joinedAt;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
}

33
ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomSessionInfo.java

@ -0,0 +1,33 @@
package com.ruoyi.websocket.dto;
import java.io.Serializable;
/**
* WebSocket 会话与房间关联信息用于断开连接时清理
*
* @author ruoyi
*/
public class RoomSessionInfo implements Serializable {
private static final long serialVersionUID = 1L;
/** 房间ID */
private Long roomId;
/** 成员信息 */
private RoomMemberDTO member;
public Long getRoomId() {
return roomId;
}
public void setRoomId(Long roomId) {
this.roomId = roomId;
}
public RoomMemberDTO getMember() {
return member;
}
public void setMember(RoomMemberDTO member) {
this.member = member;
}
}

51
ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java

@ -0,0 +1,51 @@
package com.ruoyi.websocket.listener;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import com.ruoyi.websocket.dto.RoomSessionInfo;
import com.ruoyi.websocket.service.RoomOnlineMemberBroadcastService;
import com.ruoyi.websocket.service.RoomWebSocketService;
/**
* WebSocket 断开连接监听器
* 当客户端断开刷新关闭标签页网络异常等自动从房间移除该会话避免同一账号重复出现在在线列表中
*
* @author ruoyi
*/
@Component
public class WebSocketDisconnectListener implements ApplicationListener<SessionDisconnectEvent> {
private static final String TYPE_MEMBER_LEFT = "MEMBER_LEFT";
@Autowired
private RoomWebSocketService roomWebSocketService;
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private RoomOnlineMemberBroadcastService roomOnlineMemberBroadcastService;
@Override
public void onApplicationEvent(SessionDisconnectEvent event) {
String sessionId = event.getSessionId();
if (sessionId == null) return;
RoomSessionInfo info = roomWebSocketService.leaveBySessionId(sessionId);
if (info == null) return;
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_MEMBER_LEFT);
msg.put("member", info.getMember());
msg.put("sessionId", sessionId);
String topic = "/topic/room/" + info.getRoomId();
messagingTemplate.convertAndSend(topic, msg);
roomOnlineMemberBroadcastService.broadcastAfterMembershipChange(info.getRoomId());
}
}

109
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomChatService.java

@ -0,0 +1,109 @@
package com.ruoyi.websocket.service;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import com.alibaba.fastjson2.JSON;
/**
* 房间聊天 Redis 持久化服务
* 使用 Sorted Set 存储score 为时间戳超过 30 天自动清理
*
* @author ruoyi
*/
@Service
public class RoomChatService {
private static final String ROOM_CHAT_PREFIX = "room:";
private static final String ROOM_CHAT_USER_SUFFIX = ":user:";
private static final String ROOM_CHAT_SUFFIX = ":chat";
private static final String ROOM_CHAT_USERS_SUFFIX = ":chat:users";
private static final String PRIVATE_CHAT_PREFIX = "chat:private:";
private static final long EXPIRE_DAYS = 30;
private static final long EXPIRE_MS = EXPIRE_DAYS * 24 * 60 * 60 * 1000L;
@Autowired
@Qualifier("stringObjectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
private String roomChatKey(Long roomId, Long userId) {
return ROOM_CHAT_PREFIX + roomId + ROOM_CHAT_USER_SUFFIX + userId + ROOM_CHAT_SUFFIX;
}
private String roomChatUsersKey(Long roomId) {
return ROOM_CHAT_PREFIX + roomId + ROOM_CHAT_USERS_SUFFIX;
}
private String privateChatKey(Long userId1, Long userId2) {
long a = userId1 != null ? userId1 : 0;
long b = userId2 != null ? userId2 : 0;
return PRIVATE_CHAT_PREFIX + Math.min(a, b) + ":" + Math.max(a, b);
}
/**
* 保存群聊消息 roomId + userId key 存储
*/
public void saveGroupChat(Long roomId, Long senderUserId, Object msg) {
if (senderUserId == null) return;
String key = roomChatKey(roomId, senderUserId);
long now = System.currentTimeMillis();
String member = msg instanceof String ? (String) msg : JSON.toJSONString(msg);
redisTemplate.opsForZSet().add(key, member, now);
redisTemplate.opsForZSet().removeRangeByScore(key, Double.NEGATIVE_INFINITY, now - EXPIRE_MS);
redisTemplate.opsForSet().add(roomChatUsersKey(roomId), String.valueOf(senderUserId));
}
/**
* 获取群聊历史最近 30 天内按时间升序合并所有用户
*/
public List<Object> getGroupChatHistory(Long roomId) {
String usersKey = roomChatUsersKey(roomId);
Set<Object> userIds = redisTemplate.opsForSet().members(usersKey);
if (userIds == null || userIds.isEmpty()) return new ArrayList<>();
long min = System.currentTimeMillis() - EXPIRE_MS;
List<Object[]> merged = new ArrayList<>();
for (Object uid : userIds) {
String key = roomChatKey(roomId, Long.valueOf(String.valueOf(uid)));
Set<ZSetOperations.TypedTuple<Object>> tuples =
redisTemplate.opsForZSet().rangeByScoreWithScores(key, min, Double.POSITIVE_INFINITY);
if (tuples != null) {
for (ZSetOperations.TypedTuple<Object> t : tuples) {
merged.add(new Object[] { t.getValue(), t.getScore() != null ? t.getScore() : 0.0 });
}
}
}
merged.sort((a, b) -> Double.compare((Double) a[1], (Double) b[1]));
List<Object> result = new ArrayList<>();
for (Object[] arr : merged) {
result.add(arr[0]);
}
return result;
}
/**
* 保存私聊消息
*/
public void savePrivateChat(Long userId1, Long userId2, Object msg) {
String key = privateChatKey(userId1, userId2);
long now = System.currentTimeMillis();
String member = msg instanceof String ? (String) msg : JSON.toJSONString(msg);
redisTemplate.opsForZSet().add(key, member, now);
redisTemplate.opsForZSet().removeRangeByScore(key, Double.NEGATIVE_INFINITY, now - EXPIRE_MS);
}
/**
* 获取私聊历史最近 30 天内按时间升序
*/
public List<Object> getPrivateChatHistory(Long userId1, Long userId2) {
String key = privateChatKey(userId1, userId2);
long min = System.currentTimeMillis() - EXPIRE_MS;
Set<Object> set = redisTemplate.opsForZSet().rangeByScore(key, min, Double.POSITIVE_INFINITY);
return set != null ? new ArrayList<>(set) : new ArrayList<>();
}
}

115
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomOnlineMemberBroadcastService.java

@ -0,0 +1,115 @@
package com.ruoyi.websocket.service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import com.ruoyi.system.domain.Rooms;
import com.ruoyi.system.service.IRoomsService;
import com.ruoyi.websocket.dto.RoomMemberDTO;
/**
* 大房间与子房间在线成员联动子房间展示本房+父房间大房间展示本房+所有子房间
* 成员增删后向所有相关房间的订阅 topic 广播聚合后的 MEMBER_LIST
*/
@Service
public class RoomOnlineMemberBroadcastService {
private static final String TYPE_MEMBER_LIST = "MEMBER_LIST";
@Autowired
private RoomWebSocketService roomWebSocketService;
@Autowired
private IRoomsService roomsService;
@Autowired
private SimpMessagingTemplate messagingTemplate;
/**
* 视角房间下应展示的在线成员已按 sessionId 去重
*/
public List<RoomMemberDTO> aggregateMembersForView(Long viewRoomId) {
if (viewRoomId == null) {
return new ArrayList<>();
}
Rooms room = roomsService.selectRoomsById(viewRoomId);
if (room == null) {
return new ArrayList<>();
}
LinkedHashMap<String, RoomMemberDTO> bySession = new LinkedHashMap<>();
if (room.getParentId() != null) {
putAllMembers(bySession, viewRoomId, roomWebSocketService.getRoomMembers(viewRoomId));
putAllMembers(bySession, room.getParentId(), roomWebSocketService.getRoomMembers(room.getParentId()));
} else {
putAllMembers(bySession, viewRoomId, roomWebSocketService.getRoomMembers(viewRoomId));
Rooms query = new Rooms();
query.setParentId(viewRoomId);
List<Rooms> children = roomsService.selectRoomsList(query);
if (children != null) {
for (Rooms child : children) {
if (child != null && child.getId() != null) {
putAllMembers(bySession, child.getId(), roomWebSocketService.getRoomMembers(child.getId()));
}
}
}
}
return new ArrayList<>(bySession.values());
}
private void putAllMembers(Map<String, RoomMemberDTO> bySession, Long sourceRoomId, List<RoomMemberDTO> list) {
if (list == null) {
return;
}
for (RoomMemberDTO m : list) {
if (m != null && m.getSessionId() != null) {
// 兼容历史数据:老成员可能没有 roomId 字段
if (m.getRoomId() == null) {
m.setRoomId(sourceRoomId);
}
bySession.put(m.getSessionId(), m);
}
}
}
/**
* 在指定房间发生加入/离开后刷新该房间其父房间若有或所有子房间若为大房间的在线列表推送
*/
public void broadcastAfterMembershipChange(Long changedRoomId) {
if (changedRoomId == null) {
return;
}
Rooms changed = roomsService.selectRoomsById(changedRoomId);
if (changed == null) {
return;
}
Set<Long> topicRoomIds = new HashSet<>();
topicRoomIds.add(changedRoomId);
if (changed.getParentId() != null) {
topicRoomIds.add(changed.getParentId());
} else {
Rooms query = new Rooms();
query.setParentId(changedRoomId);
List<Rooms> children = roomsService.selectRoomsList(query);
if (children != null) {
for (Rooms child : children) {
if (child != null && child.getId() != null) {
topicRoomIds.add(child.getId());
}
}
}
}
for (Long tid : topicRoomIds) {
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_MEMBER_LIST);
msg.put("members", aggregateMembersForView(tid));
messagingTemplate.convertAndSend("/topic/room/" + tid, msg);
}
}
}

61
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomRoomStateService.java

@ -0,0 +1,61 @@
package com.ruoyi.websocket.service;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
/**
* 房间状态服务持久化房间内可见航线等状态供新加入用户同步
*/
@Service
public class RoomRoomStateService {
private static final String ROOM_VISIBLE_ROUTES_PREFIX = "room:";
private static final String ROOM_VISIBLE_ROUTES_SUFFIX = ":visibleRoutes";
private static final int EXPIRE_HOURS = 24;
@Autowired
@Qualifier("stringObjectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
private String visibleRoutesKey(Long roomId) {
return ROOM_VISIBLE_ROUTES_PREFIX + roomId + ROOM_VISIBLE_ROUTES_SUFFIX;
}
/** 更新航线可见状态 */
public void updateRouteVisibility(Long roomId, Long routeId, boolean visible) {
if (roomId == null || routeId == null) return;
String key = visibleRoutesKey(roomId);
if (visible) {
redisTemplate.opsForSet().add(key, routeId);
} else {
redisTemplate.opsForSet().remove(key, routeId);
}
redisTemplate.expire(key, EXPIRE_HOURS, TimeUnit.HOURS);
}
/** 获取房间内当前可见的航线 ID 集合 */
@SuppressWarnings("unchecked")
public Set<Long> getVisibleRouteIds(Long roomId) {
if (roomId == null) return new HashSet<>();
String key = visibleRoutesKey(roomId);
Set<Object> raw = redisTemplate.opsForSet().members(key);
Set<Long> result = new HashSet<>();
if (raw != null) {
for (Object o : raw) {
if (o instanceof Number) {
result.add(((Number) o).longValue());
} else if (o != null) {
try {
result.add(Long.parseLong(o.toString()));
} catch (NumberFormatException ignored) {}
}
}
}
return result;
}
}

158
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomWebSocketService.java

@ -0,0 +1,158 @@
package com.ruoyi.websocket.service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.websocket.dto.RoomMemberDTO;
import com.ruoyi.websocket.dto.RoomSessionInfo;
/**
* WebSocket 房间 Redis 管理服务
*
* @author ruoyi
*/
@Service
public class RoomWebSocketService {
private static final String ROOM_MEMBERS_PREFIX = "room:";
private static final String ROOM_MEMBERS_SUFFIX = ":members";
private static final String USER_SESSIONS_PREFIX = "user:";
private static final String USER_SESSIONS_SUFFIX = ":sessions";
private static final String SESSION_PREFIX = "session:";
private static final int SESSION_EXPIRE_MINUTES = 30;
@Autowired
private RedisCache redisCache;
@Autowired
@Qualifier("stringObjectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
private String roomMembersKey(Long roomId) {
return ROOM_MEMBERS_PREFIX + roomId + ROOM_MEMBERS_SUFFIX;
}
private String userSessionsKey(Long userId) {
return USER_SESSIONS_PREFIX + userId + USER_SESSIONS_SUFFIX;
}
private String sessionKey(String sessionId) {
return SESSION_PREFIX + sessionId;
}
/**
* 用户加入房间
* 同一用户只保留最新会话加入前会先移除该用户在房间内的所有旧会话避免刷新/重连后重复显示
*/
public void joinRoom(Long roomId, String sessionId, RoomMemberDTO member) {
removeStaleSessionsForUser(roomId, member.getUserId(), sessionId);
String key = roomMembersKey(roomId);
redisCache.setCacheMapValue(key, sessionId, member);
redisCache.expire(key, 24, TimeUnit.HOURS);
redisTemplate.opsForSet().add(userSessionsKey(member.getUserId()), sessionId);
RoomSessionInfo sessionInfo = new RoomSessionInfo();
sessionInfo.setRoomId(roomId);
sessionInfo.setMember(member);
redisCache.setCacheObject(sessionKey(sessionId), sessionInfo, SESSION_EXPIRE_MINUTES, TimeUnit.MINUTES);
}
/**
* 移除同一用户在房间内的旧会话历史残留如服务重启前未收到断开事件前端每次加载生成新 deviceId 导致无法按设备匹配
*/
private void removeStaleSessionsForUser(Long roomId, Long userId, String currentSessionId) {
String key = roomMembersKey(roomId);
Map<String, Object> map = redisCache.getCacheMap(key);
if (map == null || map.isEmpty()) return;
List<String> toRemove = new ArrayList<>();
for (Map.Entry<String, Object> e : map.entrySet()) {
String sid = e.getKey();
if (sid.equals(currentSessionId)) continue;
Object val = e.getValue();
RoomMemberDTO dto = val instanceof RoomMemberDTO ? (RoomMemberDTO) val
: JSON.parseObject(JSON.toJSONString(val), RoomMemberDTO.class);
if (dto != null && userId.equals(dto.getUserId())) {
toRemove.add(sid);
}
}
for (String sid : toRemove) {
Object val = redisCache.getCacheMapValue(key, sid);
RoomMemberDTO dto = val instanceof RoomMemberDTO ? (RoomMemberDTO) val
: (val != null ? JSON.parseObject(JSON.toJSONString(val), RoomMemberDTO.class) : null);
leaveRoom(roomId, sid, dto != null ? dto.getUserId() : null);
}
}
/**
* 用户离开房间
*/
public void leaveRoom(Long roomId, String sessionId, Long userId) {
String key = roomMembersKey(roomId);
redisCache.deleteCacheMapValue(key, sessionId);
if (userId != null) {
redisTemplate.opsForSet().remove(userSessionsKey(userId), sessionId);
}
redisCache.deleteObject(sessionKey(sessionId));
}
/**
* 获取房间成员列表
*/
public List<RoomMemberDTO> getRoomMembers(Long roomId) {
String key = roomMembersKey(roomId);
Map<String, Object> map = redisCache.getCacheMap(key);
List<RoomMemberDTO> list = new ArrayList<>();
if (map != null && !map.isEmpty()) {
for (Object val : map.values()) {
if (val != null) {
RoomMemberDTO dto = val instanceof RoomMemberDTO ? (RoomMemberDTO) val : JSON.parseObject(JSON.toJSONString(val), RoomMemberDTO.class);
if (dto != null) list.add(dto);
}
}
}
return list;
}
/**
* 刷新会话心跳延长过期时间
*/
public void refreshSessionHeartbeat(String sessionId) {
String key = sessionKey(sessionId);
Object val = redisCache.getCacheObject(key);
if (val != null) {
redisCache.setCacheObject(key, val, SESSION_EXPIRE_MINUTES, TimeUnit.MINUTES);
}
}
/**
* 根据会话ID离开房间用于连接断开时清理
*
* @param sessionId 会话ID
* @return 若该会话在房间中返回会话信息 roomIdmember用于广播否则返回 null
*/
public RoomSessionInfo leaveBySessionId(String sessionId) {
Object val = redisCache.getCacheObject(sessionKey(sessionId));
if (val == null) return null;
RoomSessionInfo info = val instanceof RoomSessionInfo ? (RoomSessionInfo) val
: JSON.parseObject(JSON.toJSONString(val), RoomSessionInfo.class);
if (info == null || info.getRoomId() == null || info.getMember() == null) return null;
Long roomId = info.getRoomId();
RoomMemberDTO member = info.getMember();
leaveRoom(roomId, sessionId, member.getUserId());
return info;
}
}

156
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WhiteboardRoomService.java

@ -0,0 +1,156 @@
package com.ruoyi.websocket.service;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import com.alibaba.fastjson2.JSON;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
/**
* 白板房间服务按房间维度将白板数据存储于 Redis
*/
@Service
public class WhiteboardRoomService {
private static final String ROOM_WHITEBOARDS_PREFIX = "room:";
private static final String ROOM_WHITEBOARDS_SUFFIX = ":whiteboards";
private static final String WHITEBOARD_SCHEME_PLATFORM_STYLE_PREFIX = "whiteboard:scheme:";
private static final int EXPIRE_DAYS = 60;
@Autowired
@Qualifier("stringObjectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
private String whiteboardsKey(Long roomId) {
return ROOM_WHITEBOARDS_PREFIX + roomId + ROOM_WHITEBOARDS_SUFFIX;
}
private String whiteboardPlatformStyleKey(String schemeId, String platformInstanceId) {
return WHITEBOARD_SCHEME_PLATFORM_STYLE_PREFIX + schemeId + ":platform:" + platformInstanceId + ":style";
}
/** 获取房间下所有白板列表 */
@SuppressWarnings("unchecked")
public List<Object> listWhiteboards(Long roomId) {
if (roomId == null) return new ArrayList<>();
String key = whiteboardsKey(roomId);
Object raw = redisTemplate.opsForValue().get(key);
if (raw == null) return new ArrayList<>();
if (raw instanceof List) return (List<Object>) raw;
if (raw instanceof String) {
try {
return JSON.parseArray((String) raw);
} catch (Exception e) {
return new ArrayList<>();
}
}
return new ArrayList<>();
}
/** 获取单个白板详情 */
public Object getWhiteboard(Long roomId, String whiteboardId) {
List<Object> list = listWhiteboards(roomId);
for (Object item : list) {
if (item instanceof java.util.Map) {
Object id = ((java.util.Map<?, ?>) item).get("id");
if (whiteboardId.equals(String.valueOf(id))) return item;
}
}
return null;
}
/** 创建白板 */
public Object createWhiteboard(Long roomId, Object whiteboard) {
if (roomId == null || whiteboard == null) return null;
List<Object> list = listWhiteboards(roomId);
String id = UUID.randomUUID().toString();
Object wb = whiteboard;
if (wb instanceof java.util.Map) {
((java.util.Map<String, Object>) wb).put("id", id);
if (!((java.util.Map<String, Object>) wb).containsKey("name")) {
((java.util.Map<String, Object>) wb).put("name", "草稿");
}
if (!((java.util.Map<String, Object>) wb).containsKey("timeBlocks")) {
((java.util.Map<String, Object>) wb).put("timeBlocks", new ArrayList<String>());
}
if (!((java.util.Map<String, Object>) wb).containsKey("contentByTime")) {
((java.util.Map<String, Object>) wb).put("contentByTime", new java.util.HashMap<String, Object>());
}
}
list.add(wb);
saveWhiteboards(roomId, list);
return wb;
}
/** 更新白板 */
public boolean updateWhiteboard(Long roomId, String whiteboardId, Object whiteboard) {
if (roomId == null || whiteboardId == null || whiteboard == null) return false;
List<Object> list = listWhiteboards(roomId);
for (int i = 0; i < list.size(); i++) {
Object item = list.get(i);
if (item instanceof java.util.Map) {
Object id = ((java.util.Map<?, ?>) item).get("id");
if (whiteboardId.equals(String.valueOf(id))) {
if (whiteboard instanceof java.util.Map) {
((java.util.Map<String, Object>) whiteboard).put("id", whiteboardId);
}
list.set(i, whiteboard);
saveWhiteboards(roomId, list);
return true;
}
}
}
return false;
}
/** 删除白板 */
public boolean deleteWhiteboard(Long roomId, String whiteboardId) {
if (roomId == null || whiteboardId == null) return false;
List<Object> list = listWhiteboards(roomId);
boolean removed = list.removeIf(item -> {
if (item instanceof java.util.Map) {
Object id = ((java.util.Map<?, ?>) item).get("id");
return whiteboardId.equals(String.valueOf(id));
}
return false;
});
if (removed) saveWhiteboards(roomId, list);
return removed;
}
private void saveWhiteboards(Long roomId, List<Object> list) {
String key = whiteboardsKey(roomId);
redisTemplate.opsForValue().set(key, list, EXPIRE_DAYS, TimeUnit.DAYS);
}
/** 保存白板平台样式(颜色、大小等) */
public void saveWhiteboardPlatformStyle(String schemeId, String platformInstanceId, Object style) {
if (schemeId == null || schemeId.trim().isEmpty() || platformInstanceId == null || platformInstanceId.trim().isEmpty() || style == null) {
return;
}
String key = whiteboardPlatformStyleKey(schemeId, platformInstanceId);
redisTemplate.opsForValue().set(key, style, EXPIRE_DAYS, TimeUnit.DAYS);
}
/** 获取白板平台样式(颜色、大小等) */
public Object getWhiteboardPlatformStyle(String schemeId, String platformInstanceId) {
if (schemeId == null || schemeId.trim().isEmpty() || platformInstanceId == null || platformInstanceId.trim().isEmpty()) {
return null;
}
String key = whiteboardPlatformStyleKey(schemeId, platformInstanceId);
return redisTemplate.opsForValue().get(key);
}
/** 删除白板平台样式(平台删除时可清理) */
public void deleteWhiteboardPlatformStyle(String schemeId, String platformInstanceId) {
if (schemeId == null || schemeId.trim().isEmpty() || platformInstanceId == null || platformInstanceId.trim().isEmpty()) {
return;
}
String key = whiteboardPlatformStyleKey(schemeId, platformInstanceId);
redisTemplate.delete(key);
}
}

2
ruoyi-admin/src/main/resources/application-druid.yml

@ -8,7 +8,7 @@ spring:
master:
url: jdbc:mysql://localhost:3306/ry?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: A20040303ctw!
password: 123456
# 从库数据源
slave:
# 从数据源开关/默认关闭

8
ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java

@ -105,6 +105,14 @@ public class RuoYiConfig
}
/**
* 获取协同房间用户头像上传路径与平台文件目录隔离
*/
public static String getRoomUserAvatarPath()
{
return getProfile() + "/room-user-avatar";
}
/**
* 获取下载路径
*/
public static String getDownloadPath()

5
ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java

@ -13,6 +13,11 @@ public class CacheConstants
public static final String LOGIN_TOKEN_KEY = "login_tokens:";
/**
* 用户ID -> 当前活跃 token 映射用于单设备登录新登录顶掉旧会话
*/
public static final String LOGIN_USER_ID_TOKEN_KEY = "login_tokens:user:";
/**
* 验证码 redis key
*/
public static final String CAPTCHA_CODE_KEY = "captcha_codes:";

89
ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysTimelineSegment.java

@ -0,0 +1,89 @@
package com.ruoyi.common.core.domain.entity;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
public class SysTimelineSegment
{
private static final long serialVersionUID = 1L;
private Long segmentId;
private Long roomId;
@NotBlank(message = "时间点不能为空")
@Size(min = 0, max = 8, message = "时间点长度不能超过8个字符")
private String segmentTime;
@NotBlank(message = "时间段名称不能为空")
@Size(min = 0, max = 50, message = "时间段名称长度不能超过50个字符")
private String segmentName;
@Size(max = 500, message = "时间段描述长度不能超过500个字符")
private String segmentDesc;
public Long getSegmentId()
{
return segmentId;
}
public void setSegmentId(Long segmentId)
{
this.segmentId = segmentId;
}
@NotNull(message = "房间ID不能为空")
public Long getRoomId()
{
return roomId;
}
public void setRoomId(Long roomId)
{
this.roomId = roomId;
}
public String getSegmentTime()
{
return segmentTime;
}
public void setSegmentTime(String segmentTime)
{
this.segmentTime = segmentTime;
}
public String getSegmentName()
{
return segmentName;
}
public void setSegmentName(String segmentName)
{
this.segmentName = segmentName;
}
public String getSegmentDesc()
{
return segmentDesc;
}
public void setSegmentDesc(String segmentDesc)
{
this.segmentDesc = segmentDesc;
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
.append("segmentId", getSegmentId())
.append("roomId", getRoomId())
.append("segmentTime", getSegmentTime())
.append("segmentName", getSegmentName())
.append("segmentDesc", getSegmentDesc())
.toString();
}
}

14
ruoyi-common/src/main/java/com/ruoyi/common/utils/ServletUtils.java

@ -138,9 +138,21 @@ public class ServletUtils
*/
public static void renderString(HttpServletResponse response, String string)
{
renderString(response, string, 200);
}
/**
* 将字符串渲染到客户端可指定 HTTP 状态码用于 401 等认证失败场景
*
* @param response 渲染对象
* @param string 待渲染的字符串
* @param status HTTP 状态码
*/
public static void renderString(HttpServletResponse response, String string, int status)
{
try
{
response.setStatus(200);
response.setStatus(status);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);

43
ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java

@ -25,7 +25,7 @@ public class RedisConfig extends CachingConfigurerSupport
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
@ -39,6 +39,47 @@ public class RedisConfig extends CachingConfigurerSupport
return template;
}
/**
* 配置 RedisTemplate<String, Object>
* 解决 RoutesController 中注入 RedisTemplate<String, Object> 失败的问题
*/
@Bean
public RedisTemplate<String, Object> stringObjectRedisTemplate(RedisConnectionFactory connectionFactory)
{
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
/**
* 纯字符串 RedisTemplate用于存储 4T JSON 字符串避免 FastJson 序列化问题
* 命名为 fourTRedisTemplate 避免与 Spring Boot 自带的 stringRedisTemplate 冲突
*/
@Bean("fourTRedisTemplate")
public RedisTemplate<String, String> fourTRedisTemplate(RedisConnectionFactory connectionFactory)
{
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
@Bean
public DefaultRedisScript<Long> limitScript()
{

2
ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java

@ -109,6 +109,8 @@ public class SecurityConfig {
permitAllUrl.getUrls().forEach(url -> requests.antMatchers(url).permitAll());
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
requests.antMatchers("/login", "/register", "/captchaImage").permitAll()
// WebSocket 握手(鉴权在 HandshakeInterceptor 中完成)
.antMatchers("/ws/**").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()

3
ruoyi-framework/src/main/java/com/ruoyi/framework/security/handle/AuthenticationEntryPointImpl.java

@ -29,6 +29,7 @@ public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, S
{
int code = HttpStatus.UNAUTHORIZED;
String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
// 使用 HTTP 401 状态码,便于前端 axios error 拦截器识别并触发重新登录(含被顶掉场景)
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)), HttpStatus.UNAUTHORIZED);
}
}

30
ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java

@ -94,30 +94,51 @@ public class TokenService
}
/**
* 删除用户身份信息
* 删除用户身份信息同时清除用户- token 映射便于单设备登录
*/
public void delLoginUser(String token)
{
if (StringUtils.isNotEmpty(token))
{
String userKey = getTokenKey(token);
LoginUser loginUser = redisCache.getCacheObject(userKey);
if (loginUser != null && loginUser.getUserId() != null) {
redisCache.deleteObject(CacheConstants.LOGIN_USER_ID_TOKEN_KEY + loginUser.getUserId());
}
redisCache.deleteObject(userKey);
}
}
/**
* 创建令牌
* 创建令牌单设备登录新登录会顶掉该账号在其他设备的旧会话
*
* @param loginUser 用户信息
* @return 令牌
*/
public String createToken(LoginUser loginUser)
{
// 踢掉该账号在其他设备的旧会话
Long userId = loginUser.getUserId();
if (userId != null) {
String userTokenKey = CacheConstants.LOGIN_USER_ID_TOKEN_KEY + userId;
String oldToken = redisCache.getCacheObject(userTokenKey);
if (StringUtils.isNotEmpty(oldToken)) {
redisCache.deleteObject(getTokenKey(oldToken));
log.info("用户[{}]新设备登录,已踢掉旧会话 token={}", loginUser.getUsername(), oldToken);
}
}
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);
refreshToken(loginUser);
// 记录该用户当前活跃的 token(用于下次登录时踢掉)
if (userId != null) {
String userTokenKey = CacheConstants.LOGIN_USER_ID_TOKEN_KEY + userId;
redisCache.setCacheObject(userTokenKey, token, expireTime, TimeUnit.MINUTES);
}
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
claims.put(Constants.JWT_USERNAME, loginUser.getUsername());
@ -152,6 +173,11 @@ public class TokenService
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
// 同步刷新用户- token 映射的过期时间
if (loginUser.getUserId() != null) {
String userTokenKey = CacheConstants.LOGIN_USER_ID_TOKEN_KEY + loginUser.getUserId();
redisCache.setCacheObject(userTokenKey, loginUser.getToken(), expireTime, TimeUnit.MINUTES);
}
}
/**

13
ruoyi-system/src/main/java/com/ruoyi/system/domain/MissionScenario.java

@ -38,6 +38,9 @@ public class MissionScenario extends BaseEntity
@Excel(name = "是否为当前默认展示方案")
private Integer isActive;
/** 大房间ID(查询用,非持久化):传入时查询 room_id IN (该大房间下所有子房间ID) */
private Long parentRoomId;
public void setId(Long id)
{
this.id = id;
@ -98,6 +101,16 @@ public class MissionScenario extends BaseEntity
return isActive;
}
public Long getParentRoomId()
{
return parentRoomId;
}
public void setParentRoomId(Long parentRoomId)
{
this.parentRoomId = parentRoomId;
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)

88
ruoyi-system/src/main/java/com/ruoyi/system/domain/ObjectOperationLog.java

@ -0,0 +1,88 @@
package com.ruoyi.system.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 对象级操作日志航线航点平台等支持回滚
*
* @author ruoyi
*/
public class ObjectOperationLog extends BaseEntity {
private static final long serialVersionUID = 1L;
/** 操作类型:新增 */
public static final int TYPE_INSERT = 1;
/** 操作类型:修改 */
public static final int TYPE_UPDATE = 2;
/** 操作类型:删除 */
public static final int TYPE_DELETE = 3;
/** 操作类型:选择 */
public static final int TYPE_SELECT = 4;
/** 操作类型:回滚 */
public static final int TYPE_ROLLBACK = 5;
/** 对象类型:航线 */
public static final String OBJ_ROUTE = "route";
/** 对象类型:航点 */
public static final String OBJ_WAYPOINT = "waypoint";
/** 对象类型:平台 */
public static final String OBJ_PLATFORM = "platform";
/** 主键 */
private Long id;
/** 房间ID */
private Long roomId;
/** 操作人用户ID */
private Long operatorId;
/** 操作人姓名 */
private String operatorName;
/** 操作类型 1新增 2修改 3删除 4选择 */
private Integer operationType;
/** 操作对象类型 route/waypoint/platform */
private String objectType;
/** 业务对象ID */
private String objectId;
/** 对象显示名 */
private String objectName;
/** 详细操作描述 */
private String detail;
/** 操作前快照JSON,用于回滚 */
private String snapshotBefore;
/** 操作后快照JSON */
private String snapshotAfter;
/** 相对时间 K+00:00:00 */
private String kTime;
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createdAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getRoomId() { return roomId; }
public void setRoomId(Long roomId) { this.roomId = roomId; }
public Long getOperatorId() { return operatorId; }
public void setOperatorId(Long operatorId) { this.operatorId = operatorId; }
public String getOperatorName() { return operatorName; }
public void setOperatorName(String operatorName) { this.operatorName = operatorName; }
public Integer getOperationType() { return operationType; }
public void setOperationType(Integer operationType) { this.operationType = operationType; }
public String getObjectType() { return objectType; }
public void setObjectType(String objectType) { this.objectType = objectType; }
public String getObjectId() { return objectId; }
public void setObjectId(String objectId) { this.objectId = objectId; }
public String getObjectName() { return objectName; }
public void setObjectName(String objectName) { this.objectName = objectName; }
public String getDetail() { return detail; }
public void setDetail(String detail) { this.detail = detail; }
public String getSnapshotBefore() { return snapshotBefore; }
public void setSnapshotBefore(String snapshotBefore) { this.snapshotBefore = snapshotBefore; }
public String getSnapshotAfter() { return snapshotAfter; }
public void setSnapshotAfter(String snapshotAfter) { this.snapshotAfter = snapshotAfter; }
public String getkTime() { return kTime; }
public void setkTime(String kTime) { this.kTime = kTime; }
public Date getCreatedAt() { return createdAt; }
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
}

47
ruoyi-system/src/main/java/com/ruoyi/system/domain/RoomUserProfile.java

@ -0,0 +1,47 @@
package com.ruoyi.system.domain;
import com.ruoyi.common.core.domain.BaseEntity;
/**
* 协同房间用户资料显示名头像
*/
public class RoomUserProfile extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long id;
private Long userId;
private String displayName;
private String avatar;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getAvatar() {
return avatar;
}
public void setAvatar(String avatar) {
this.avatar = avatar;
}
}

92
ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java

@ -1,6 +1,7 @@
package com.ruoyi.system.domain;
import java.math.BigDecimal;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.ruoyi.common.annotation.Excel;
@ -63,11 +64,8 @@ public class RouteWaypoints extends BaseEntity
@Excel(name = "盘旋参数")
private String holdParams;
/** 航点标签文字大小(px),用于地图显示 */
private Integer labelFontSize;
/** 航点标签文字颜色(如 #333333),用于地图显示 */
private String labelColor;
/** 航点显示样式 JSON:字号、文字颜色、标记大小与颜色等,对应表 display_style 列 */
private WaypointDisplayStyle displayStyle;
public void setId(Long id)
{
@ -185,20 +183,93 @@ public class RouteWaypoints extends BaseEntity
return holdParams;
}
@JsonIgnore
public WaypointDisplayStyle getDisplayStyle() {
return displayStyle;
}
@JsonIgnore
public void setDisplayStyle(WaypointDisplayStyle displayStyle) {
this.displayStyle = displayStyle;
}
private WaypointDisplayStyle getOrCreateDisplayStyle() {
if (displayStyle == null) {
displayStyle = new WaypointDisplayStyle();
}
return displayStyle;
}
/** API/前端:地图上航点名称字号,默认 16 */
public void setLabelFontSize(Integer labelFontSize) {
this.labelFontSize = labelFontSize;
getOrCreateDisplayStyle().setLabelFontSize(labelFontSize);
}
public Integer getLabelFontSize() {
return labelFontSize;
return displayStyle != null && displayStyle.getLabelFontSize() != null ? displayStyle.getLabelFontSize() : 16;
}
/** API/前端:地图上航点名称颜色,默认 #000000 */
public void setLabelColor(String labelColor) {
this.labelColor = labelColor;
getOrCreateDisplayStyle().setLabelColor(labelColor);
}
public String getLabelColor() {
return labelColor;
return displayStyle != null && displayStyle.getLabelColor() != null ? displayStyle.getLabelColor() : "#000000";
}
/** API/前端:航点圆点填充色,默认 #ffffff */
public void setColor(String color) {
getOrCreateDisplayStyle().setColor(color);
}
public String getColor() {
return displayStyle != null && displayStyle.getColor() != null ? displayStyle.getColor() : "#ffffff";
}
/** API/前端:航点圆点直径(像素),默认 12 */
public void setPixelSize(Integer pixelSize) {
getOrCreateDisplayStyle().setPixelSize(pixelSize);
}
public Integer getPixelSize() {
return displayStyle != null && displayStyle.getPixelSize() != null ? displayStyle.getPixelSize() : 12;
}
/** API/前端:航点圆点边框颜色,默认 #000000 */
public void setOutlineColor(String outlineColor) {
getOrCreateDisplayStyle().setOutlineColor(outlineColor);
}
public String getOutlineColor() {
return displayStyle != null && displayStyle.getOutlineColor() != null ? displayStyle.getOutlineColor() : "#000000";
}
/** 航段模式:fixed_speed-定速,fixed_time-定时,空或null-普通 */
public String getSegmentMode() {
return displayStyle != null ? displayStyle.getSegmentMode() : null;
}
public void setSegmentMode(String segmentMode) {
getOrCreateDisplayStyle().setSegmentMode(segmentMode);
}
/** 定时目标(分):fixed_time 时表示期望到达本航点的相对K时,可与相对K时分离支持“定时到达+盘旋至相对K时出发” */
public Double getSegmentTargetMinutes() {
return displayStyle != null ? displayStyle.getSegmentTargetMinutes() : null;
}
public void setSegmentTargetMinutes(Double segmentTargetMinutes) {
getOrCreateDisplayStyle().setSegmentTargetMinutes(segmentTargetMinutes);
}
/** 定速目标(km/h):fixed_speed 时表示本航段固定速度 */
public Double getSegmentTargetSpeed() {
return displayStyle != null ? displayStyle.getSegmentTargetSpeed() : null;
}
public void setSegmentTargetSpeed(Double segmentTargetSpeed) {
getOrCreateDisplayStyle().setSegmentTargetSpeed(segmentTargetSpeed);
}
@Override
@ -216,8 +287,7 @@ public class RouteWaypoints extends BaseEntity
.append("turnAngle", getTurnAngle())
.append("pointType", getPointType())
.append("holdParams", getHoldParams())
.append("labelFontSize", getLabelFontSize())
.append("labelColor", getLabelColor())
.append("displayStyle", displayStyle)
.toString();
}

34
ruoyi-system/src/main/java/com/ruoyi/system/domain/Routes.java

@ -47,6 +47,12 @@ public class Routes extends BaseEntity {
private List<RouteWaypoints> waypoints;
/** 方案ID列表(查询用,非持久化):传入时查询 scenario_id IN (...) */
private java.util.List<Long> scenarioIds;
/** 方案ID逗号分隔(查询用,便于前端传参):如 "1,2,3" */
private String scenarioIdsStr;
/** 关联的平台信息(仅用于 API 返回,非数据库字段) */
private java.util.Map<String, Object> platform;
@ -98,6 +104,34 @@ public class Routes extends BaseEntity {
this.waypoints = waypoints;
}
public java.util.List<Long> getScenarioIds() {
return scenarioIds;
}
public void setScenarioIds(java.util.List<Long> scenarioIds) {
this.scenarioIds = scenarioIds;
}
public String getScenarioIdsStr() {
return scenarioIdsStr;
}
public void setScenarioIdsStr(String scenarioIdsStr) {
this.scenarioIdsStr = scenarioIdsStr;
if (scenarioIdsStr != null && !scenarioIdsStr.trim().isEmpty()) {
java.util.List<Long> list = new java.util.ArrayList<>();
for (String s : scenarioIdsStr.split(",")) {
s = s.trim();
if (!s.isEmpty()) {
try {
list.add(Long.parseLong(s));
} catch (NumberFormatException ignored) {}
}
}
this.scenarioIds = list.isEmpty() ? null : list;
}
}
public java.util.Map<String, Object> getPlatform() {
return platform;
}

92
ruoyi-system/src/main/java/com/ruoyi/system/domain/WaypointDisplayStyle.java

@ -0,0 +1,92 @@
package com.ruoyi.system.domain;
import java.io.Serializable;
/**
* 航点显示样式地图上名称字号/颜色标记大小/颜色对应表字段 display_style JSON 结构
*/
public class WaypointDisplayStyle implements Serializable {
private static final long serialVersionUID = 1L;
/** 航点名称在地图上的字号,默认 16 */
private Integer labelFontSize;
/** 航点名称在地图上的颜色,默认 #000000 */
private String labelColor;
/** 航点圆点填充色,默认 #ffffff */
private String color;
/** 航点圆点直径(像素),默认 12 */
private Integer pixelSize;
/** 航点圆点边框颜色,默认 #000000 */
private String outlineColor;
/** 航段模式:fixed_speed-定速(移动下一航点改下一航点相对K时), fixed_time-定时(移动本航点改上一航点速度), 空或null-普通 */
private String segmentMode;
/** 定时目标(分):fixed_time 时表示期望到达本航点的相对K时(分),与相对K时分离后可支持“定时到达+盘旋至相对K时出发” */
private Double segmentTargetMinutes;
/** 定速目标(km/h):fixed_speed 时表示本航段使用的固定速度,用于重算下一航点相对K时 */
private Double segmentTargetSpeed;
public Integer getLabelFontSize() {
return labelFontSize;
}
public void setLabelFontSize(Integer labelFontSize) {
this.labelFontSize = labelFontSize;
}
public String getLabelColor() {
return labelColor;
}
public void setLabelColor(String labelColor) {
this.labelColor = labelColor;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public Integer getPixelSize() {
return pixelSize;
}
public void setPixelSize(Integer pixelSize) {
this.pixelSize = pixelSize;
}
public String getOutlineColor() {
return outlineColor;
}
public void setOutlineColor(String outlineColor) {
this.outlineColor = outlineColor;
}
public String getSegmentMode() {
return segmentMode;
}
public void setSegmentMode(String segmentMode) {
this.segmentMode = segmentMode;
}
public Double getSegmentTargetMinutes() {
return segmentTargetMinutes;
}
public void setSegmentTargetMinutes(Double segmentTargetMinutes) {
this.segmentTargetMinutes = segmentTargetMinutes;
}
public Double getSegmentTargetSpeed() {
return segmentTargetSpeed;
}
public void setSegmentTargetSpeed(Double segmentTargetSpeed) {
this.segmentTargetSpeed = segmentTargetSpeed;
}
}

348
ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/PlatformStyleDTO.java

@ -0,0 +1,348 @@
package com.ruoyi.system.domain.dto;
import java.io.Serializable;
import java.util.List;
/**
* 平台样式 DTO
*/
public class PlatformStyleDTO implements Serializable {
private static final long serialVersionUID = 1L;
private String roomId;
private Long routeId;
private Long platformId;
/** 独立平台图标实例 ID(拖到地图上的每条记录的 id,非平台类型 id)。routeId=0 时用此字段作 Redis hash field,实现每个实例独立样式 */
private Long platformIconInstanceId;
/** 平台名称 */
private String platformName;
/** 标牌字体大小 */
private Integer labelFontSize;
/** 标牌字体颜色 */
private String labelFontColor;
/** 平台大小 */
private Integer platformSize;
/** 平台颜色 */
private String platformColor;
/** 多探测区配置(支持叠加与独立显隐) */
private List<DetectionZoneDTO> detectionZones;
/** 多威力区配置(支持叠加与独立显隐) */
private List<PowerZoneDTO> powerZones;
/** 探测区半径(千米),整圆 */
private Double detectionZoneRadius;
/** 探测区填充颜色 */
private String detectionZoneColor;
/** 探测区透明度 0-1 */
private Double detectionZoneOpacity;
/** 探测区是否在地图上显示 */
private Boolean detectionZoneVisible;
/** 威力区半径(千米) */
private Double powerZoneRadius;
/** 威力区扇形夹角(度),如 60 表示 60 度扇形 */
private Double powerZoneAngle;
/** 威力区填充颜色 */
private String powerZoneColor;
/** 威力区透明度 0-1 */
private Double powerZoneOpacity;
/** 威力区是否在地图上显示 */
private Boolean powerZoneVisible;
public static class DetectionZoneDTO implements Serializable {
private static final long serialVersionUID = 1L;
/** zone 唯一 id(前端生成,后端原样保存) */
private String zoneId;
/** 探测区半径(千米) */
private Double radiusKm;
/** 探测区填充颜色(css rgba 或 hex) */
private String color;
/** 探测区透明度 0-1 */
private Double opacity;
/** 是否在地图上显示 */
private Boolean visible;
public String getZoneId() {
return zoneId;
}
public void setZoneId(String zoneId) {
this.zoneId = zoneId;
}
public Double getRadiusKm() {
return radiusKm;
}
public void setRadiusKm(Double radiusKm) {
this.radiusKm = radiusKm;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public Double getOpacity() {
return opacity;
}
public void setOpacity(Double opacity) {
this.opacity = opacity;
}
public Boolean getVisible() {
return visible;
}
public void setVisible(Boolean visible) {
this.visible = visible;
}
}
public static class PowerZoneDTO implements Serializable {
private static final long serialVersionUID = 1L;
/** zone 唯一 id(前端生成,后端原样保存) */
private String zoneId;
/** 威力区半径(千米) */
private Double radiusKm;
/** 威力区扇形夹角(度) */
private Double angleDeg;
/** 威力区填充颜色(css rgba 或 hex) */
private String color;
/** 威力区透明度 0-1 */
private Double opacity;
/** 是否在地图上显示 */
private Boolean visible;
public String getZoneId() {
return zoneId;
}
public void setZoneId(String zoneId) {
this.zoneId = zoneId;
}
public Double getRadiusKm() {
return radiusKm;
}
public void setRadiusKm(Double radiusKm) {
this.radiusKm = radiusKm;
}
public Double getAngleDeg() {
return angleDeg;
}
public void setAngleDeg(Double angleDeg) {
this.angleDeg = angleDeg;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public Double getOpacity() {
return opacity;
}
public void setOpacity(Double opacity) {
this.opacity = opacity;
}
public Boolean getVisible() {
return visible;
}
public void setVisible(Boolean visible) {
this.visible = visible;
}
}
public List<DetectionZoneDTO> getDetectionZones() {
return detectionZones;
}
public void setDetectionZones(List<DetectionZoneDTO> detectionZones) {
this.detectionZones = detectionZones;
}
public List<PowerZoneDTO> getPowerZones() {
return powerZones;
}
public void setPowerZones(List<PowerZoneDTO> powerZones) {
this.powerZones = powerZones;
}
public String getRoomId() {
return roomId;
}
public void setRoomId(String roomId) {
this.roomId = roomId;
}
public Long getRouteId() {
return routeId;
}
public void setRouteId(Long routeId) {
this.routeId = routeId;
}
public Long getPlatformId() {
return platformId;
}
public void setPlatformId(Long platformId) {
this.platformId = platformId;
}
public Long getPlatformIconInstanceId() {
return platformIconInstanceId;
}
public void setPlatformIconInstanceId(Long platformIconInstanceId) {
this.platformIconInstanceId = platformIconInstanceId;
}
public String getPlatformName() {
return platformName;
}
public void setPlatformName(String platformName) {
this.platformName = platformName;
}
public Integer getLabelFontSize() {
return labelFontSize;
}
public void setLabelFontSize(Integer labelFontSize) {
this.labelFontSize = labelFontSize;
}
public String getLabelFontColor() {
return labelFontColor;
}
public void setLabelFontColor(String labelFontColor) {
this.labelFontColor = labelFontColor;
}
public Integer getPlatformSize() {
return platformSize;
}
public void setPlatformSize(Integer platformSize) {
this.platformSize = platformSize;
}
public String getPlatformColor() {
return platformColor;
}
public void setPlatformColor(String platformColor) {
this.platformColor = platformColor;
}
public Double getPowerZoneRadius() {
return powerZoneRadius;
}
public void setPowerZoneRadius(Double powerZoneRadius) {
this.powerZoneRadius = powerZoneRadius;
}
public String getPowerZoneColor() {
return powerZoneColor;
}
public void setPowerZoneColor(String powerZoneColor) {
this.powerZoneColor = powerZoneColor;
}
public Double getDetectionZoneRadius() {
return detectionZoneRadius;
}
public void setDetectionZoneRadius(Double detectionZoneRadius) {
this.detectionZoneRadius = detectionZoneRadius;
}
public String getDetectionZoneColor() {
return detectionZoneColor;
}
public void setDetectionZoneColor(String detectionZoneColor) {
this.detectionZoneColor = detectionZoneColor;
}
public Double getDetectionZoneOpacity() {
return detectionZoneOpacity;
}
public void setDetectionZoneOpacity(Double detectionZoneOpacity) {
this.detectionZoneOpacity = detectionZoneOpacity;
}
public Boolean getDetectionZoneVisible() {
return detectionZoneVisible;
}
public void setDetectionZoneVisible(Boolean detectionZoneVisible) {
this.detectionZoneVisible = detectionZoneVisible;
}
public Double getPowerZoneAngle() {
return powerZoneAngle;
}
public void setPowerZoneAngle(Double powerZoneAngle) {
this.powerZoneAngle = powerZoneAngle;
}
public Double getPowerZoneOpacity() {
return powerZoneOpacity;
}
public void setPowerZoneOpacity(Double powerZoneOpacity) {
this.powerZoneOpacity = powerZoneOpacity;
}
public Boolean getPowerZoneVisible() {
return powerZoneVisible;
}
public void setPowerZoneVisible(Boolean powerZoneVisible) {
this.powerZoneVisible = powerZoneVisible;
}
}

23
ruoyi-system/src/main/java/com/ruoyi/system/mapper/ObjectOperationLogMapper.java

@ -0,0 +1,23 @@
package com.ruoyi.system.mapper;
import java.util.List;
import com.ruoyi.system.domain.ObjectOperationLog;
/**
* 对象级操作日志 Mapper
*
* @author ruoyi
*/
public interface ObjectOperationLogMapper {
int insert(ObjectOperationLog log);
ObjectOperationLog selectById(Long id);
List<ObjectOperationLog> selectPage(ObjectOperationLog query);
int deleteById(Long id);
/** 删除某条之后的所有日志(回滚时清理后续日志,可选策略) */
int deleteByRoomIdAfterId(Long roomId, Long afterId);
}

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

@ -36,6 +36,14 @@ public interface PlatformLibMapper
public int insertPlatformLib(PlatformLib platformLib);
/**
* 新增平台模版库带指定 ID用于删除回滚时恢复原主键
*
* @param platformLib 平台模版库
* @return 结果
*/
public int insertPlatformLibWithId(PlatformLib platformLib);
/**
* 修改平台模版库
*
* @param platformLib 平台模版库

12
ruoyi-system/src/main/java/com/ruoyi/system/mapper/RoomUserProfileMapper.java

@ -0,0 +1,12 @@
package com.ruoyi.system.mapper;
import com.ruoyi.system.domain.RoomUserProfile;
public interface RoomUserProfileMapper {
RoomUserProfile selectByUserId(Long userId);
int insertRoomUserProfile(RoomUserProfile profile);
int updateRoomUserProfile(RoomUserProfile profile);
}

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

@ -1,6 +1,7 @@
package com.ruoyi.system.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.system.domain.RouteWaypoints;
/**
@ -28,7 +29,10 @@ 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);
/**
* 新增航线具体航点明细
@ -52,7 +56,7 @@ public interface RouteWaypointsMapper
* @param id 航线具体航点明细主键
* @return 结果
*/
public int deleteRouteWaypointsById(Long id);
public int deleteRouteWaypointsById(@Param("id") Long id);
/**
* 删除航线具体航点明细
@ -60,7 +64,7 @@ public interface RouteWaypointsMapper
* @param routeId 航线主键
* @return 结果
*/
public int deleteRouteWaypointsByRouteId(Long routeId);
public int deleteRouteWaypointsByRouteId(@Param("routeId") Long routeId);
/**
* 批量删除航线具体航点明细
@ -68,5 +72,5 @@ public interface RouteWaypointsMapper
* @param ids 需要删除的数据主键集合
* @return 结果
*/
public int deleteRouteWaypointsByIds(Long[] ids);
public int deleteRouteWaypointsByIds(@Param("ids") Long[] ids);
}

22
ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysTimelineSegmentMapper.java

@ -0,0 +1,22 @@
package com.ruoyi.system.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.common.core.domain.entity.SysTimelineSegment;
public interface SysTimelineSegmentMapper
{
public List<SysTimelineSegment> selectSegmentList(SysTimelineSegment segment);
public List<SysTimelineSegment> selectSegmentListByRoomId(@Param("roomId") Long roomId);
public SysTimelineSegment selectSegmentById(Long segmentId);
public int insertSegment(SysTimelineSegment segment);
public int updateSegment(SysTimelineSegment segment);
public int deleteSegmentById(Long segmentId);
public int deleteSegmentByRoomId(@Param("roomId") Long roomId);
}

33
ruoyi-system/src/main/java/com/ruoyi/system/service/IObjectOperationLogService.java

@ -0,0 +1,33 @@
package com.ruoyi.system.service;
import java.util.List;
import com.ruoyi.system.domain.ObjectOperationLog;
/**
* 对象级操作日志 服务接口
*
* @author ruoyi
*/
public interface IObjectOperationLogService {
/**
* 记录一条操作日志同时写库并推送到 Redis 缓存
*/
void saveLog(ObjectOperationLog log);
/**
* 分页查询优先从 Redis 取第一页近期数据以减轻数据库压力
*/
List<ObjectOperationLog> selectPage(ObjectOperationLog query);
/**
* 根据ID查询回滚时需要快照
*/
ObjectOperationLog selectById(Long id);
/**
* 回滚到指定日志 snapshot_before 恢复数据并同步 Redis 缓存
* @return 是否成功
*/
boolean rollback(Long logId);
}

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

12
ruoyi-system/src/main/java/com/ruoyi/system/service/IRoomUserProfileService.java

@ -0,0 +1,12 @@
package com.ruoyi.system.service;
import com.ruoyi.system.domain.RoomUserProfile;
public interface IRoomUserProfileService {
RoomUserProfile getOrInitByUser(Long userId, String defaultUserName);
RoomUserProfile updateDisplayName(Long userId, String displayName, String defaultUserName);
RoomUserProfile updateAvatar(Long userId, String avatar, String defaultUserName);
}

21
ruoyi-system/src/main/java/com/ruoyi/system/service/ISysTimelineSegmentService.java

@ -0,0 +1,21 @@
package com.ruoyi.system.service;
import java.util.List;
import com.ruoyi.common.core.domain.entity.SysTimelineSegment;
public interface ISysTimelineSegmentService
{
public List<SysTimelineSegment> selectSegmentList(SysTimelineSegment segment);
public List<SysTimelineSegment> selectSegmentListByRoomId(Long roomId);
public SysTimelineSegment selectSegmentById(Long segmentId);
public int insertSegment(SysTimelineSegment segment);
public int updateSegment(SysTimelineSegment segment);
public int deleteSegmentById(Long segmentId);
public int deleteSegmentByRoomId(Long roomId);
}

219
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ObjectOperationLogServiceImpl.java

@ -0,0 +1,219 @@
package com.ruoyi.system.service.impl;
import java.util.List;
import java.util.concurrent.TimeUnit;
import com.ruoyi.system.domain.PlatformLib;
import com.ruoyi.system.domain.RouteWaypoints;
import com.ruoyi.system.domain.Routes;
import com.ruoyi.system.mapper.PlatformLibMapper;
import com.ruoyi.system.mapper.RouteWaypointsMapper;
import com.ruoyi.system.mapper.RoutesMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.mapper.ObjectOperationLogMapper;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IRouteWaypointsService;
/**
* 对象级操作日志 服务实现写库 + Redis 缓存回滚时同步 DB Redis
*/
@Service
public class ObjectOperationLogServiceImpl implements IObjectOperationLogService {
private static final String REDIS_KEY_PREFIX = "object_log:room:";
private static final int REDIS_LIST_MAX = 500;
private static final long REDIS_EXPIRE_HOURS = 24;
@Autowired
private ObjectOperationLogMapper objectOperationLogMapper;
@Autowired
@Qualifier("stringObjectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RoutesMapper routesMapper;
@Autowired
private RouteWaypointsMapper routeWaypointsMapper;
@Autowired
private IRouteWaypointsService routeWaypointsService;
@Autowired
private PlatformLibMapper platformLibMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public void saveLog(ObjectOperationLog log) {
objectOperationLogMapper.insert(log);
if (log.getRoomId() != null) {
String key = REDIS_KEY_PREFIX + log.getRoomId();
redisTemplate.opsForList().rightPush(key, log);
redisTemplate.opsForList().trim(key, -REDIS_LIST_MAX, -1);
redisTemplate.expire(key, REDIS_EXPIRE_HOURS, TimeUnit.HOURS);
}
}
@Override
public List<ObjectOperationLog> selectPage(ObjectOperationLog query) {
return objectOperationLogMapper.selectPage(query);
}
@Override
public ObjectOperationLog selectById(Long id) {
return objectOperationLogMapper.selectById(id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean rollback(Long logId) {
ObjectOperationLog log = objectOperationLogMapper.selectById(logId);
if (log == null) return false;
Integer opType = log.getOperationType();
if (opType == null) return false;
if (ObjectOperationLog.TYPE_INSERT == opType) {
return rollbackInsert(log);
}
if (ObjectOperationLog.TYPE_DELETE == opType) {
return rollbackDelete(log);
}
if (ObjectOperationLog.TYPE_UPDATE == opType && log.getSnapshotBefore() != null && !log.getSnapshotBefore().isEmpty()) {
return rollbackUpdate(log);
}
return false;
}
private boolean rollbackUpdate(ObjectOperationLog log) {
String objectType = log.getObjectType();
if (ObjectOperationLog.OBJ_ROUTE.equals(objectType)) return rollbackRouteUpdate(log);
if (ObjectOperationLog.OBJ_WAYPOINT.equals(objectType)) return rollbackWaypointUpdate(log);
if (ObjectOperationLog.OBJ_PLATFORM.equals(objectType)) {
return rollbackPlatformUpdate(log);
}
return false;
}
private boolean rollbackDelete(ObjectOperationLog log) {
if (log.getSnapshotBefore() == null || log.getSnapshotBefore().isEmpty()) return false;
String objectType = log.getObjectType();
if (ObjectOperationLog.OBJ_ROUTE.equals(objectType)) return rollbackRouteReinsert(log);
if (ObjectOperationLog.OBJ_WAYPOINT.equals(objectType)) return rollbackWaypointReinsert(log);
if (ObjectOperationLog.OBJ_PLATFORM.equals(objectType)) {
return rollbackPlatformReinsert(log);
}
return false;
}
private boolean rollbackInsert(ObjectOperationLog log) {
if (log.getSnapshotAfter() == null || log.getSnapshotAfter().isEmpty()) return false;
String objectType = log.getObjectType();
if (ObjectOperationLog.OBJ_ROUTE.equals(objectType)) {
Routes r = JSON.parseObject(log.getSnapshotAfter(), Routes.class);
if (r != null && r.getId() != null) {
routeWaypointsService.deleteRouteWaypointsByRouteId(r.getId());
routesMapper.deleteRoutesById(r.getId());
invalidateRoomCache(log.getRoomId());
return true;
}
}
if (ObjectOperationLog.OBJ_WAYPOINT.equals(objectType)) {
RouteWaypoints wp = JSON.parseObject(log.getSnapshotAfter(), RouteWaypoints.class);
if (wp != null && wp.getId() != null) {
routeWaypointsMapper.deleteRouteWaypointsById(wp.getId());
invalidateRoomCache(log.getRoomId());
return true;
}
}
if (ObjectOperationLog.OBJ_PLATFORM.equals(objectType)) {
PlatformLib lib = JSON.parseObject(log.getSnapshotAfter(), PlatformLib.class);
if (lib != null && lib.getId() != null) {
platformLibMapper.deletePlatformLibById(lib.getId());
invalidateRoomCache(log.getRoomId());
return true;
}
}
return false;
}
private boolean rollbackRouteUpdate(ObjectOperationLog log) {
Routes before = JSON.parseObject(log.getSnapshotBefore(), Routes.class);
if (before == null || before.getId() == null) return false;
routeWaypointsService.deleteRouteWaypointsByRouteId(before.getId());
routesMapper.updateRoutes(before);
if (before.getWaypoints() != null && !before.getWaypoints().isEmpty()) {
for (RouteWaypoints wp : before.getWaypoints()) {
wp.setRouteId(before.getId());
routeWaypointsMapper.insertRouteWaypoints(wp);
}
}
invalidateRoomCache(log.getRoomId());
return true;
}
private boolean rollbackWaypointUpdate(ObjectOperationLog log) {
RouteWaypoints before = JSON.parseObject(log.getSnapshotBefore(), RouteWaypoints.class);
if (before == null || before.getId() == null) return false;
routeWaypointsMapper.updateRouteWaypoints(before);
invalidateRoomCache(log.getRoomId());
return true;
}
private boolean rollbackRouteReinsert(ObjectOperationLog log) {
Routes before = JSON.parseObject(log.getSnapshotBefore(), Routes.class);
if (before == null) return false;
before.setId(null);
routesMapper.insertRoutes(before);
if (before.getWaypoints() != null && !before.getWaypoints().isEmpty()) {
for (RouteWaypoints wp : before.getWaypoints()) {
wp.setId(null);
wp.setRouteId(before.getId());
routeWaypointsMapper.insertRouteWaypoints(wp);
}
}
invalidateRoomCache(log.getRoomId());
return true;
}
private boolean rollbackWaypointReinsert(ObjectOperationLog log) {
RouteWaypoints before = JSON.parseObject(log.getSnapshotBefore(), RouteWaypoints.class);
if (before == null) return false;
before.setId(null);
routeWaypointsMapper.insertRouteWaypoints(before);
invalidateRoomCache(log.getRoomId());
return true;
}
private boolean rollbackPlatformUpdate(ObjectOperationLog log) {
PlatformLib before = JSON.parseObject(log.getSnapshotBefore(), PlatformLib.class);
if (before == null || before.getId() == null) return false;
platformLibMapper.updatePlatformLib(before);
invalidateRoomCache(log.getRoomId());
return true;
}
private boolean rollbackPlatformReinsert(ObjectOperationLog log) {
PlatformLib before = JSON.parseObject(log.getSnapshotBefore(), PlatformLib.class);
if (before == null || before.getId() == null) return false;
// 如果该 ID 已经存在(例如之前已手动恢复过),则视为已回滚成功,避免主键冲突
PlatformLib existed = platformLibMapper.selectPlatformLibById(before.getId());
if (existed == null) {
platformLibMapper.insertPlatformLibWithId(before);
}
invalidateRoomCache(log.getRoomId());
return true;
}
private void invalidateRoomCache(Long roomId) {
if (roomId == null) return;
String key = REDIS_KEY_PREFIX + roomId;
redisTemplate.delete(key);
}
}

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);
}

63
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RoomUserProfileServiceImpl.java

@ -0,0 +1,63 @@
package com.ruoyi.system.service.impl;
import java.util.Date;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.domain.RoomUserProfile;
import com.ruoyi.system.mapper.RoomUserProfileMapper;
import com.ruoyi.system.service.IRoomUserProfileService;
@Service
public class RoomUserProfileServiceImpl implements IRoomUserProfileService {
public static final String DEFAULT_AVATAR =
"https://cube.elemecdn.com/0/88dd03f9bf287d08f58fbcf58fddbf4a8c6/avatar.png";
@Autowired
private RoomUserProfileMapper roomUserProfileMapper;
@Override
public RoomUserProfile getOrInitByUser(Long userId, String defaultUserName) {
if (userId == null) {
return null;
}
RoomUserProfile profile = roomUserProfileMapper.selectByUserId(userId);
if (profile != null) {
return profile;
}
RoomUserProfile created = new RoomUserProfile();
created.setUserId(userId);
created.setDisplayName(StringUtils.isNotEmpty(defaultUserName) ? defaultUserName : "用户" + userId);
created.setAvatar(DEFAULT_AVATAR);
Date now = new Date();
created.setCreateTime(now);
created.setUpdateTime(now);
roomUserProfileMapper.insertRoomUserProfile(created);
return roomUserProfileMapper.selectByUserId(userId);
}
@Override
public RoomUserProfile updateDisplayName(Long userId, String displayName, String defaultUserName) {
RoomUserProfile current = getOrInitByUser(userId, defaultUserName);
if (current == null) {
return null;
}
current.setDisplayName(StringUtils.isNotEmpty(displayName) ? displayName : current.getDisplayName());
current.setUpdateTime(new Date());
roomUserProfileMapper.updateRoomUserProfile(current);
return roomUserProfileMapper.selectByUserId(userId);
}
@Override
public RoomUserProfile updateAvatar(Long userId, String avatar, String defaultUserName) {
RoomUserProfile current = getOrInitByUser(userId, defaultUserName);
if (current == null) {
return null;
}
current.setAvatar(StringUtils.isNotEmpty(avatar) ? avatar : current.getAvatar());
current.setUpdateTime(new Date());
roomUserProfileMapper.updateRoomUserProfile(current);
return roomUserProfileMapper.selectByUserId(userId);
}
}

9
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RouteWaypointsServiceImpl.java

@ -52,11 +52,14 @@ public class RouteWaypointsServiceImpl implements IRouteWaypointsService
@Override
public int insertRouteWaypoints(RouteWaypoints routeWaypoints)
{
// 1. 获取该航线当前的最高序号
Long requestedSeq = routeWaypoints.getSeq();
Integer maxSeq = routeWaypointsMapper.selectMaxSeqByRouteId(routeWaypoints.getRouteId());
// 2. 如果是第一条,序号为1;否则在最大值基础上 +1
if (maxSeq == null) {
// 若前端传入有效 seq(在指定位置插入),则先将该位置及之后的航点 seq 均加 1,再插入
if (requestedSeq != null && requestedSeq > 0 && maxSeq != null && requestedSeq <= maxSeq) {
routeWaypointsMapper.incrementSeqFrom(routeWaypoints.getRouteId(), requestedSeq);
// 使用前端传入的 seq
} else if (maxSeq == null) {
routeWaypoints.setSeq(1L);
} else {
routeWaypoints.setSeq((long) (maxSeq + 1));

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;

57
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysTimelineSegmentServiceImpl.java

@ -0,0 +1,57 @@
package com.ruoyi.system.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.core.domain.entity.SysTimelineSegment;
import com.ruoyi.system.mapper.SysTimelineSegmentMapper;
import com.ruoyi.system.service.ISysTimelineSegmentService;
@Service
public class SysTimelineSegmentServiceImpl implements ISysTimelineSegmentService
{
@Autowired
private SysTimelineSegmentMapper segmentMapper;
@Override
public List<SysTimelineSegment> selectSegmentList(SysTimelineSegment segment)
{
return segmentMapper.selectSegmentList(segment);
}
@Override
public List<SysTimelineSegment> selectSegmentListByRoomId(Long roomId)
{
return segmentMapper.selectSegmentListByRoomId(roomId);
}
@Override
public SysTimelineSegment selectSegmentById(Long segmentId)
{
return segmentMapper.selectSegmentById(segmentId);
}
@Override
public int insertSegment(SysTimelineSegment segment)
{
return segmentMapper.insertSegment(segment);
}
@Override
public int updateSegment(SysTimelineSegment segment)
{
return segmentMapper.updateSegment(segment);
}
@Override
public int deleteSegmentById(Long segmentId)
{
return segmentMapper.deleteSegmentById(segmentId);
}
@Override
public int deleteSegmentByRoomId(Long roomId)
{
return segmentMapper.deleteSegmentByRoomId(roomId);
}
}

59
ruoyi-system/src/main/java/com/ruoyi/system/typehandler/WaypointDisplayStyleTypeHandler.java

@ -0,0 +1,59 @@
package com.ruoyi.system.typehandler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.system.domain.WaypointDisplayStyle;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* MyBatis 类型处理器display_style TEXT/JSON WaypointDisplayStyle 互转
*/
@MappedTypes(WaypointDisplayStyle.class)
public class WaypointDisplayStyleTypeHandler extends BaseTypeHandler<WaypointDisplayStyle> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public void setNonNullParameter(PreparedStatement ps, int i, WaypointDisplayStyle parameter, JdbcType jdbcType) throws SQLException {
try {
ps.setString(i, MAPPER.writeValueAsString(parameter));
} catch (Exception e) {
throw new SQLException("WaypointDisplayStyle serialize error", e);
}
}
@Override
public WaypointDisplayStyle getNullableResult(ResultSet rs, String columnName) throws SQLException {
String json = rs.getString(columnName);
return parse(json);
}
@Override
public WaypointDisplayStyle getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String json = rs.getString(columnIndex);
return parse(json);
}
@Override
public WaypointDisplayStyle getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String json = cs.getString(columnIndex);
return parse(json);
}
private static WaypointDisplayStyle parse(String json) {
if (json == null || json.trim().isEmpty()) {
return null;
}
try {
return MAPPER.readValue(json, WaypointDisplayStyle.class);
} catch (Exception e) {
return null;
}
}
}

3
ruoyi-system/src/main/resources/mapper/system/MissionScenarioMapper.xml

@ -20,7 +20,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectMissionScenarioList" parameterType="MissionScenario" resultMap="MissionScenarioResult">
<include refid="selectMissionScenarioVo"/>
<where>
<if test="roomId != null "> and room_id = #{roomId}</if>
<if test="parentRoomId != null"> and room_id in (select id from ry.rooms where parent_id = #{parentRoomId})</if>
<if test="parentRoomId == null and roomId != null "> and room_id = #{roomId}</if>
<if test="name != null and name != ''"> and name like concat('%', #{name}, '%')</if>
<if test="version != null and version != ''"> and version = #{version}</if>
<if test="frontendDrawings != null and frontendDrawings != ''"> and frontend_drawings = #{frontendDrawings}</if>

56
ruoyi-system/src/main/resources/mapper/system/ObjectOperationLogMapper.xml

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.ObjectOperationLogMapper">
<resultMap type="ObjectOperationLog" id="ObjectOperationLogResult">
<id property="id" column="id" />
<result property="roomId" column="room_id" />
<result property="operatorId" column="operator_id" />
<result property="operatorName" column="operator_name" />
<result property="operationType" column="operation_type"/>
<result property="objectType" column="object_type" />
<result property="objectId" column="object_id" />
<result property="objectName" column="object_name" />
<result property="detail" column="detail" />
<result property="snapshotBefore" column="snapshot_before"/>
<result property="snapshotAfter" column="snapshot_after" />
<result property="kTime" column="k_time" />
<result property="createdAt" column="created_at" />
</resultMap>
<sql id="selectVo">
select id, room_id, operator_id, operator_name, operation_type, object_type, object_id, object_name,
detail, snapshot_before, snapshot_after, k_time, created_at
from object_operation_log
</sql>
<insert id="insert" parameterType="ObjectOperationLog" useGeneratedKeys="true" keyProperty="id">
insert into object_operation_log (room_id, operator_id, operator_name, operation_type, object_type, object_id, object_name, detail, snapshot_before, snapshot_after, k_time)
values (#{roomId}, #{operatorId}, #{operatorName}, #{operationType}, #{objectType}, #{objectId}, #{objectName}, #{detail}, #{snapshotBefore}, #{snapshotAfter}, #{kTime})
</insert>
<select id="selectById" resultMap="ObjectOperationLogResult">
<include refid="selectVo"/> where id = #{id}
</select>
<select id="selectPage" parameterType="ObjectOperationLog" resultMap="ObjectOperationLogResult">
<include refid="selectVo"/>
<where>
<if test="roomId != null"> and room_id = #{roomId} </if>
<if test="operatorName != null and operatorName != ''"> and operator_name like concat('%', #{operatorName}, '%') </if>
<if test="operationType != null"> and operation_type = #{operationType} </if>
<if test="objectType != null and objectType != ''"> and object_type = #{objectType} </if>
<if test="params != null and params.beginTime != null and params.beginTime != ''"> and created_at &gt;= #{params.beginTime} </if>
<if test="params != null and params.endTime != null and params.endTime != ''"> and created_at &lt;= #{params.endTime} </if>
</where>
order by created_at desc
</select>
<delete id="deleteById" parameterType="Long">
delete from object_operation_log where id = #{id}
</delete>
<delete id="deleteByRoomIdAfterId">
delete from object_operation_log where room_id = #{roomId} and id > #{afterId}
</delete>
</mapper>

19
ruoyi-system/src/main/resources/mapper/system/PlatformLibMapper.xml

@ -68,4 +68,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{id}
</foreach>
</delete>
<!-- 删除回滚场景下,按原 ID 重新插入平台模版库 -->
<insert id="insertPlatformLibWithId" parameterType="PlatformLib">
insert into platform_lib
<trim prefix="(" suffix=")" suffixOverrides=",">
id,
<if test="name != null and name != ''">name,</if>
<if test="type != null and type != ''">type,</if>
<if test="specsJson != null and specsJson != ''">specs_json,</if>
<if test="iconUrl != null and iconUrl != ''">icon_url,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
#{id},
<if test="name != null and name != ''">#{name},</if>
<if test="type != null and type != ''">#{type},</if>
<if test="specsJson != null and specsJson != ''">#{specsJson},</if>
<if test="iconUrl != null and iconUrl != ''">#{iconUrl},</if>
</trim>
</insert>
</mapper>

54
ruoyi-system/src/main/resources/mapper/system/RoomUserProfileMapper.xml

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.RoomUserProfileMapper">
<resultMap type="com.ruoyi.system.domain.RoomUserProfile" id="RoomUserProfileResult">
<result property="id" column="id"/>
<result property="userId" column="user_id"/>
<result property="displayName" column="display_name"/>
<result property="avatar" column="avatar"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<sql id="selectRoomUserProfileVo">
select id, user_id, display_name, avatar, create_time, update_time
from room_user_profile
</sql>
<select id="selectByUserId" parameterType="Long" resultMap="RoomUserProfileResult">
<include refid="selectRoomUserProfileVo"/>
where user_id = #{userId}
limit 1
</select>
<insert id="insertRoomUserProfile" parameterType="com.ruoyi.system.domain.RoomUserProfile" useGeneratedKeys="true" keyProperty="id">
insert into room_user_profile
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="userId != null">user_id,</if>
<if test="displayName != null">display_name,</if>
<if test="avatar != null">avatar,</if>
<if test="createTime != null">create_time,</if>
<if test="updateTime != null">update_time,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="userId != null">#{userId},</if>
<if test="displayName != null">#{displayName},</if>
<if test="avatar != null">#{avatar},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateTime != null">#{updateTime},</if>
</trim>
</insert>
<update id="updateRoomUserProfile" parameterType="com.ruoyi.system.domain.RoomUserProfile">
update room_user_profile
<trim prefix="SET" suffixOverrides=",">
<if test="displayName != null">display_name = #{displayName},</if>
<if test="avatar != null">avatar = #{avatar},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</trim>
where user_id = #{userId}
</update>
</mapper>

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

@ -17,17 +17,16 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="turnAngle" column="turn_angle" />
<result property="pointType" column="point_type" />
<result property="holdParams" column="hold_params" />
<result property="labelFontSize" column="label_font_size" />
<result property="labelColor" column="label_color" />
<result property="displayStyle" column="display_style" typeHandler="com.ruoyi.system.typehandler.WaypointDisplayStyleTypeHandler" />
</resultMap>
<sql id="selectRouteWaypointsVo">
select id, route_id, name, seq, lat, lng, alt, speed, start_time, turn_angle, point_type, hold_params, label_font_size, label_color from route_waypoints
select id, route_id, name, seq, lat, lng, alt, speed, start_time, turn_angle, point_type, hold_params, display_style from route_waypoints
</sql>
<select id="selectRouteWaypointsList" parameterType="RouteWaypoints" resultMap="RouteWaypointsResult">
<include refid="selectRouteWaypointsVo"/>
<where>
<where>
<if test="routeId != null "> and route_id = #{routeId}</if>
<if test="name != null and name != ''"> and name like concat('%', #{name}, '%')</if>
<if test="seq != null "> and seq = #{seq}</if>
@ -40,6 +39,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="pointType != null and pointType != ''"> and point_type = #{pointType}</if>
<if test="holdParams != null and holdParams != ''"> and hold_params = #{holdParams}</if>
</where>
order by seq asc
</select>
<select id="selectRouteWaypointsById" parameterType="Long" resultMap="RouteWaypointsResult">
@ -51,6 +51,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select max(seq) from ry.route_waypoints where route_id = #{routeId}
</select>
<update id="incrementSeqFrom">
update route_waypoints set seq = seq + 1 where route_id = #{routeId} and seq &gt;= #{seq}
</update>
<insert id="insertRouteWaypoints" parameterType="RouteWaypoints" useGeneratedKeys="true" keyProperty="id">
insert into route_waypoints
<trim prefix="(" suffix=")" suffixOverrides=",">
@ -65,8 +69,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="turnAngle != null">turn_angle,</if>
<if test="pointType != null and pointType != ''">point_type,</if>
<if test="holdParams != null">hold_params,</if>
<if test="labelFontSize != null">label_font_size,</if>
<if test="labelColor != null">label_color,</if>
<if test="displayStyle != null">display_style,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="routeId != null">#{routeId},</if>
@ -80,8 +83,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="turnAngle != null">#{turnAngle},</if>
<if test="pointType != null and pointType != ''">#{pointType},</if>
<if test="holdParams != null">#{holdParams},</if>
<if test="labelFontSize != null">#{labelFontSize},</if>
<if test="labelColor != null">#{labelColor},</if>
<if test="displayStyle != null">#{displayStyle, typeHandler=com.ruoyi.system.typehandler.WaypointDisplayStyleTypeHandler},</if>
</trim>
</insert>
@ -99,8 +101,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="turnAngle != null">turn_angle = #{turnAngle},</if>
<if test="pointType != null">point_type = #{pointType},</if>
<if test="holdParams != null">hold_params = #{holdParams},</if>
<if test="labelFontSize != null">label_font_size = #{labelFontSize},</if>
<if test="labelColor != null">label_color = #{labelColor},</if>
<if test="displayStyle != null">display_style = #{displayStyle, typeHandler=com.ruoyi.system.typehandler.WaypointDisplayStyleTypeHandler},</if>
</trim>
where id = #{id}
</update>
@ -113,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>

5
ruoyi-system/src/main/resources/mapper/system/RoutesMapper.xml

@ -20,7 +20,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectRoutesList" parameterType="Routes" resultMap="RoutesResult">
<include refid="selectRoutesVo"/>
<where>
<if test="scenarioId != null "> and scenario_id = #{scenarioId}</if>
<if test="scenarioIds != null and scenarioIds.size() > 0"> and scenario_id in
<foreach collection="scenarioIds" item="sid" open="(" separator="," close=")">#{sid}</foreach>
</if>
<if test="(scenarioIds == null or scenarioIds.size() == 0) and scenarioId != null "> and scenario_id = #{scenarioId}</if>
<if test="platformId != null "> and platform_id = #{platformId}</if>
<if test="callSign != null and callSign != ''"> and call_sign = #{callSign}</if>
<if test="attributes != null and attributes != ''"> and attributes = #{attributes}</if>

77
ruoyi-system/src/main/resources/mapper/system/SysTimelineSegmentMapper.xml

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.SysTimelineSegmentMapper">
<resultMap type="SysTimelineSegment" id="SysTimelineSegmentResult">
<id property="segmentId" column="segment_id" />
<result property="roomId" column="room_id" />
<result property="segmentTime" column="segment_time" />
<result property="segmentName" column="segment_name" />
<result property="segmentDesc" column="segment_desc" />
</resultMap>
<sql id="selectSegmentVo">
select s.segment_id, s.room_id, s.segment_time, s.segment_name, s.segment_desc
from sys_timeline_segment s
</sql>
<select id="selectSegmentList" parameterType="SysTimelineSegment" resultMap="SysTimelineSegmentResult">
<include refid="selectSegmentVo"/>
where 1=1
<if test="roomId != null and roomId != 0">
AND room_id = #{roomId}
</if>
<if test="segmentName != null and segmentName != ''">
AND segment_name like concat('%', #{segmentName}, '%')
</if>
order by s.segment_time
</select>
<select id="selectSegmentListByRoomId" parameterType="Long" resultMap="SysTimelineSegmentResult">
<include refid="selectSegmentVo"/>
where room_id = #{roomId}
order by s.segment_time
</select>
<select id="selectSegmentById" parameterType="Long" resultMap="SysTimelineSegmentResult">
<include refid="selectSegmentVo"/>
where segment_id = #{segmentId}
</select>
<insert id="insertSegment" parameterType="SysTimelineSegment">
insert into sys_timeline_segment(
room_id,
segment_time,
segment_name,
<if test="segmentDesc != null and segmentDesc != ''">segment_desc,</if>
segment_id
)values(
#{roomId},
#{segmentTime},
#{segmentName},
<if test="segmentDesc != null and segmentDesc != ''">#{segmentDesc},</if>
#{segmentId}
)
</insert>
<update id="updateSegment" parameterType="SysTimelineSegment">
update sys_timeline_segment
<set>
<if test="segmentTime != null and segmentTime != ''">segment_time = #{segmentTime},</if>
<if test="segmentName != null and segmentName != ''">segment_name = #{segmentName},</if>
<if test="segmentDesc != null">segment_desc = #{segmentDesc},</if>
</set>
where segment_id = #{segmentId}
</update>
<delete id="deleteSegmentById" parameterType="Long">
delete from sys_timeline_segment where segment_id = #{segmentId}
</delete>
<delete id="deleteSegmentByRoomId" parameterType="Long">
delete from sys_timeline_segment where room_id = #{roomId}
</delete>
</mapper>

7
ruoyi-ui/package.json

@ -26,6 +26,7 @@
},
"dependencies": {
"@riophae/vue-treeselect": "0.4.0",
"@stomp/stompjs": "^7.3.0",
"axios": "0.28.1",
"cesium": "^1.95.0",
"clipboard": "2.0.8",
@ -35,13 +36,16 @@
"file-saver": "2.0.5",
"fuse.js": "6.4.3",
"highlight.js": "9.18.5",
"html2canvas": "^1.4.1",
"js-beautify": "1.13.0",
"js-cookie": "3.0.1",
"jsencrypt": "3.0.0-rc.1",
"jspdf": "^2.5.2",
"mammoth": "^1.11.0",
"nprogress": "0.2.0",
"quill": "2.0.2",
"screenfull": "5.0.2",
"sockjs-client": "^1.6.1",
"sortablejs": "1.10.2",
"splitpanes": "2.4.1",
"vue": "2.6.12",
@ -67,7 +71,8 @@
"sass-loader": "10.1.1",
"script-ext-html-webpack-plugin": "2.1.5",
"svg-sprite-loader": "5.1.1",
"vue-template-compiler": "2.6.12"
"vue-template-compiler": "2.6.12",
"worker-loader": "^3.0.8"
},
"engines": {
"node": ">=8.9",

691
ruoyi-ui/public/dataCard.html

@ -0,0 +1,691 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数据卡</title>
<link rel="stylesheet" href="./element-ui.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #fafafa;
}
#app {
width: 100%;
height: 100vh;
}
.data-card-container {
display: flex;
flex-direction: column;
height: 100vh;
background: #fafafa;
}
.data-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 32px;
height: 56px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-btn {
padding: 8px 12px;
font-size: 14px;
color: #666;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.back-btn:hover {
color: #409EFF;
}
.data-card-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.data-card-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 32px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-label {
font-size: 14px;
color: #666;
}
.data-card-content {
flex: 1;
padding: 24px 32px;
overflow: auto;
}
.table-container {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 24px;
display: inline-block;
}
.data-table {
border-collapse: collapse;
table-layout: fixed;
}
.data-table td {
border: 1px solid #ddd;
min-width: 60px;
height: 30px;
position: relative;
cursor: cell;
transition: background-color 0.2s;
}
.data-table td:hover {
background-color: #f5f7fa;
}
.data-table td.selected {
background-color: #ecf5ff;
border-color: #409EFF;
}
.resize-handle-col {
position: absolute;
right: 0;
top: 0;
width: 5px;
height: 100%;
cursor: col-resize;
background: transparent;
z-index: 10;
}
.resize-handle-col:hover {
background: #409EFF;
}
.resize-handle-row {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 5px;
cursor: row-resize;
background: transparent;
z-index: 10;
}
.resize-handle-row:hover {
background: #409EFF;
}
.cell-input {
width: 100%;
height: 100%;
border: none;
outline: none;
padding: 4px 8px;
font-size: 14px;
background: transparent;
}
.color-picker-panel {
display: flex;
flex-wrap: wrap;
gap: 4px;
width: 180px;
}
.color-option {
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
border: 2px solid transparent;
}
.color-option:hover {
border-color: #409EFF;
}
.color-option.selected {
border-color: #409EFF;
}
</style>
</head>
<body>
<div id="app">
<div class="data-card-container">
<div class="data-card-header">
<div class="header-left">
<div class="back-btn" @click="goBack">
<i class="el-icon-arrow-left"></i>
返回
</div>
<div class="data-card-title">数据卡</div>
</div>
<div class="header-right">
<el-button type="primary" size="small" @click="exportImage">
<i class="el-icon-picture-outline"></i>
导出图片
</el-button>
</div>
</div>
<div class="data-card-toolbar">
<div class="toolbar-group">
<span class="toolbar-label">行数:</span>
<el-input-number v-model="rows" :min="1" :max="50" size="small" @change="updateTableSize"></el-input-number>
</div>
<div class="toolbar-group">
<span class="toolbar-label">列数:</span>
<el-input-number v-model="cols" :min="1" :max="20" size="small" @change="updateTableSize"></el-input-number>
</div>
<el-divider direction="vertical"></el-divider>
<div class="toolbar-group">
<span class="toolbar-label">单元格背景色:</span>
<el-popover
placement="bottom"
width="200"
trigger="click"
v-model="colorPopoverVisible">
<div class="color-picker-panel">
<div
v-for="color in colors"
:key="color"
class="color-option"
:style="{ backgroundColor: color }"
:class="{ selected: selectedColor === color }"
@click="selectColor(color)">
</div>
</div>
<el-button slot="reference" size="small">
<span :style="{ display: 'inline-block', width: '16px', height: '16px', backgroundColor: selectedColor, marginRight: '8px', verticalAlign: 'middle', borderRadius: '2px' }"></span>
选择颜色
</el-button>
</el-popover>
</div>
<el-divider direction="vertical"></el-divider>
<div class="toolbar-group">
<el-button size="small" @click="undo">撤回</el-button>
<el-button size="small" @click="mergeCells">合并单元格</el-button>
<el-button type="primary" size="small" @click="saveTable">保存</el-button>
<el-button type="danger" size="small" @click="clearCell">清空选中</el-button>
</div>
</div>
<div class="data-card-content">
<div class="table-container" ref="tableContainer">
<table class="data-table" ref="dataTable">
<tbody>
<tr v-for="(row, rowIndex) in tableData" :key="rowIndex">
<td
v-for="(cell, colIndex) in row"
:key="colIndex"
:class="{ selected: selectedCells.some(c => c.row === rowIndex && c.col === colIndex) }"
:rowspan="getRowspan(rowIndex, colIndex)"
:colspan="getColspan(rowIndex, colIndex)"
:style="getCellStyle(rowIndex, colIndex)"
@click="editCell(rowIndex, colIndex)"
@mousedown="startSelect($event, rowIndex, colIndex)"
@mouseover="continueSelect($event, rowIndex, colIndex)"
@mouseup="endSelect">
<div class="resize-handle-col" @mousedown.stop="startResizeCol($event, colIndex)"></div>
<div class="resize-handle-row" @mousedown.stop="startResizeRow($event, rowIndex)"></div>
<span v-if="!editingCell || editingCell.row !== rowIndex || editingCell.col !== colIndex">{{ cell.content }}</span>
<input
v-else
class="cell-input"
v-model="tableData[rowIndex][colIndex].content"
@blur="finishEditing"
@keyup.enter="finishEditing"
ref="cellInput"
autofocus>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@2.6.12/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script src="https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<script>
new Vue({
el: '#app',
data() {
return {
rows: 10,
cols: 8,
tableData: [],
cellWidths: [],
cellHeights: [],
selectedCells: [],
isSelecting: false,
startCell: null,
editingCell: null,
selectedColor: '#ffffff',
colorPopoverVisible: false,
colors: [
'#ffffff', '#f5f7fa', '#e6a23c', '#67c23a', '#409eff',
'#909399', '#f0f9eb', '#fdf6ec', '#ecf5ff', '#f4f4f5',
'#ff6b6b', '#51cf66', '#339af0', '#cc5de8', '#ff922b',
'#ffd43b', '#20c997', '#22b8cf', '#748ffc', '#be4bdb'
],
resizingCol: null,
resizingRow: null,
startX: 0,
startY: 0,
startWidth: 0,
startHeight: 0,
mergedCells: [],
history: [],
historyIndex: -1
};
},
mounted() {
this.loadTable();
if (this.tableData.length === 0) {
this.generateTable();
// 初始化历史记录
this.saveState();
}
document.addEventListener('mouseup', this.endSelect);
document.addEventListener('mousemove', this.handleResize);
document.addEventListener('mouseup', this.endResize);
},
beforeDestroy() {
document.removeEventListener('mouseup', this.endSelect);
document.removeEventListener('mousemove', this.handleResize);
document.removeEventListener('mouseup', this.endResize);
},
methods: {
goBack() {
window.close();
},
generateTable() {
this.tableData = [];
this.cellWidths = [];
this.cellHeights = [];
this.mergedCells = [];
for (let i = 0; i < this.rows; i++) {
const row = [];
for (let j = 0; j < this.cols; j++) {
row.push({ content: '', bgColor: '#ffffff' });
}
this.tableData.push(row);
this.cellHeights.push(30);
}
for (let j = 0; j < this.cols; j++) {
this.cellWidths.push(100);
}
this.selectedCells = [];
// 重置历史记录
this.history = [];
this.historyIndex = -1;
},
updateTableSize() {
const oldRows = this.tableData.length;
const oldCols = oldRows > 0 ? this.tableData[0].length : 0;
if (this.rows !== oldRows || this.cols !== oldCols) {
// 保存当前状态
this.saveState();
// 处理行的增减
if (this.rows > oldRows) {
// 增加行
for (let i = oldRows; i < this.rows; i++) {
const row = [];
for (let j = 0; j < this.cols; j++) {
row.push({ content: '', bgColor: '#ffffff' });
}
this.tableData.push(row);
this.cellHeights.push(30);
}
} else if (this.rows < oldRows) {
// 减少行
this.tableData.splice(this.rows);
this.cellHeights.splice(this.rows);
}
// 处理列的增减
if (this.cols > oldCols) {
// 增加列
for (let i = 0; i < this.tableData.length; i++) {
for (let j = oldCols; j < this.cols; j++) {
this.tableData[i].push({ content: '', bgColor: '#ffffff' });
}
}
for (let j = oldCols; j < this.cols; j++) {
this.cellWidths.push(100);
}
} else if (this.cols < oldCols) {
// 减少列
for (let i = 0; i < this.tableData.length; i++) {
this.tableData[i].splice(this.cols);
}
this.cellWidths.splice(this.cols);
}
}
},
mergeCells() {
if (this.selectedCells.length < 2) {
this.$message.warning('请至少选择两个单元格进行合并');
return;
}
// 保存当前状态
this.saveState();
// 计算合并区域的最小和最大行、列
const minRow = Math.min(...this.selectedCells.map(c => c.row));
const maxRow = Math.max(...this.selectedCells.map(c => c.row));
const minCol = Math.min(...this.selectedCells.map(c => c.col));
const maxCol = Math.max(...this.selectedCells.map(c => c.col));
// 获取左上角单元格的内容和背景色
const firstCell = this.tableData[minRow][minCol];
const content = firstCell.content;
const bgColor = firstCell.bgColor;
// 清除其他单元格的内容和背景色
for (let r = minRow; r <= maxRow; r++) {
for (let c = minCol; c <= maxCol; c++) {
if (r !== minRow || c !== minCol) {
this.tableData[r][c].content = '';
this.tableData[r][c].bgColor = '#ffffff';
}
}
}
// 记录合并信息
this.mergedCells.push({
startRow: minRow,
startCol: minCol,
endRow: maxRow,
endCol: maxCol
});
this.$message.success('单元格合并成功');
},
saveTable() {
const tableData = {
rows: this.rows,
cols: this.cols,
tableData: this.tableData,
cellWidths: this.cellWidths,
cellHeights: this.cellHeights,
mergedCells: this.mergedCells
};
localStorage.setItem('dataCardTable', JSON.stringify(tableData));
this.$message.success('表格保存成功!');
},
loadTable() {
const savedData = localStorage.getItem('dataCardTable');
if (savedData) {
try {
const data = JSON.parse(savedData);
this.rows = data.rows;
this.cols = data.cols;
this.tableData = data.tableData;
this.cellWidths = data.cellWidths;
this.cellHeights = data.cellHeights;
this.mergedCells = data.mergedCells || [];
this.$message.success('表格加载成功!');
// 初始化历史记录
this.saveState();
} catch (e) {
console.error('加载表格失败:', e);
}
}
},
saveState() {
// 保存当前状态到历史记录
const state = {
rows: this.rows,
cols: this.cols,
tableData: JSON.parse(JSON.stringify(this.tableData)),
cellWidths: [...this.cellWidths],
cellHeights: [...this.cellHeights],
mergedCells: JSON.parse(JSON.stringify(this.mergedCells))
};
// 如果当前不是在历史记录的最后,删除后面的历史记录
if (this.historyIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.historyIndex + 1);
}
// 添加新状态到历史记录
this.history.push(state);
// 限制历史记录长度,防止内存占用过大
if (this.history.length > 50) {
this.history.shift();
// 调整历史记录索引
if (this.historyIndex > 0) {
this.historyIndex--;
}
} else {
this.historyIndex++;
}
},
undo() {
if (this.historyIndex > 0) {
this.historyIndex--;
const state = this.history[this.historyIndex];
this.rows = state.rows;
this.cols = state.cols;
this.tableData = state.tableData;
this.cellWidths = state.cellWidths;
this.cellHeights = state.cellHeights;
this.mergedCells = state.mergedCells;
this.$message.success('操作已撤回');
} else {
this.$message.info('没有可撤回的操作');
}
},
startSelect(event, row, col) {
if (event.button !== 0) return;
// 只有在按下鼠标并移动时才开始选择
this.isSelecting = true;
this.startCell = { row, col };
this.selectedCells = [{ row, col }];
},
continueSelect(event, row, col) {
if (!this.isSelecting || !this.startCell) return;
const minRow = Math.min(this.startCell.row, row);
const maxRow = Math.max(this.startCell.row, row);
const minCol = Math.min(this.startCell.col, col);
const maxCol = Math.max(this.startCell.col, col);
this.selectedCells = [];
for (let r = minRow; r <= maxRow; r++) {
for (let c = minCol; c <= maxCol; c++) {
this.selectedCells.push({ row: r, col: c });
}
}
},
endSelect() {
this.isSelecting = false;
},
editCell(row, col) {
// 停止任何正在进行的选择
this.isSelecting = false;
this.selectedCells = [{ row, col }];
// 进入编辑模式
this.editingCell = { row, col };
this.$nextTick(() => {
const cellInputs = this.$refs.cellInput;
if (cellInputs) {
// 确保输入框获得焦点
if (Array.isArray(cellInputs)) {
cellInputs.forEach(input => {
if (input) input.focus();
});
} else if (cellInputs) {
cellInputs.focus();
}
}
});
},
finishEditing() {
// 保存当前状态
this.saveState();
this.editingCell = null;
},
selectColor(color) {
if (this.selectedCells.length === 0) return;
// 保存当前状态
this.saveState();
this.selectedColor = color;
this.colorPopoverVisible = false;
this.selectedCells.forEach(cell => {
this.tableData[cell.row][cell.col].bgColor = color;
});
},
clearCell() {
if (this.selectedCells.length === 0) return;
// 保存当前状态
this.saveState();
this.selectedCells.forEach(cell => {
this.tableData[cell.row][cell.col].content = '';
this.tableData[cell.row][cell.col].bgColor = '#ffffff';
});
},
startResizeCol(event, col) {
this.resizingCol = col;
this.startX = event.clientX;
this.startWidth = this.cellWidths[col];
},
startResizeRow(event, row) {
this.resizingRow = row;
this.startY = event.clientY;
this.startHeight = this.cellHeights[row];
},
handleResize(event) {
if (this.resizingCol !== null) {
const deltaX = event.clientX - this.startX;
const newWidth = Math.max(60, this.startWidth + deltaX);
this.$set(this.cellWidths, this.resizingCol, newWidth);
}
if (this.resizingRow !== null) {
const deltaY = event.clientY - this.startY;
const newHeight = Math.max(30, this.startHeight + deltaY);
this.$set(this.cellHeights, this.resizingRow, newHeight);
}
},
endResize() {
this.resizingCol = null;
this.resizingRow = null;
},
getRowspan(row, col) {
// 检查当前单元格是否是合并单元格的起始单元格
const mergedCell = this.mergedCells.find(mc => mc.startRow === row && mc.startCol === col);
return mergedCell ? mergedCell.endRow - mergedCell.startRow + 1 : 1;
},
getColspan(row, col) {
// 检查当前单元格是否是合并单元格的起始单元格
const mergedCell = this.mergedCells.find(mc => mc.startRow === row && mc.startCol === col);
return mergedCell ? mergedCell.endCol - mergedCell.startCol + 1 : 1;
},
getCellStyle(row, col) {
// 检查当前单元格是否是合并单元格的一部分
const mergedCell = this.mergedCells.find(mc =>
row >= mc.startRow && row <= mc.endRow &&
col >= mc.startCol && col <= mc.endCol
);
// 如果是合并单元格的非起始单元格,隐藏它
if (mergedCell && (row !== mergedCell.startRow || col !== mergedCell.startCol)) {
return {
display: 'none',
width: this.cellWidths[col] + 'px',
height: this.cellHeights[row] + 'px'
};
}
// 正常单元格的样式
return {
width: this.cellWidths[col] + 'px',
height: this.cellHeights[row] + 'px',
backgroundColor: this.tableData[row][col].bgColor || '#fff'
};
},
exportImage() {
const container = this.$refs.tableContainer;
html2canvas(container, {
backgroundColor: '#ffffff',
scale: 2,
useCORS: true
}).then(canvas => {
const link = document.createElement('a');
link.download = '数据卡.png';
link.href = canvas.toDataURL('image/png');
link.click();
this.$message.success('图片导出成功!');
}).catch(err => {
this.$message.error('图片导出失败:' + err.message);
});
}
}
});
</script>
</body>
</html>

1
ruoyi-ui/public/element-ui.css

File diff suppressed because one or more lines are too long

BIN
ruoyi-ui/public/fonts/element-icons.woff

Binary file not shown.

BIN
ruoyi-ui/public/logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

BIN
ruoyi-ui/public/logo2.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

630
ruoyi-ui/public/stepEditor.html

@ -1,630 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>六步法编辑器</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #fafafa;
}
#app {
width: 100%;
height: 100vh;
}
.step-editor-container {
display: flex;
flex-direction: column;
height: 100vh;
background: #fafafa;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 32px;
height: 56px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-btn {
padding: 8px 12px;
font-size: 14px;
color: #666;
}
.back-btn:hover {
color: #409EFF;
}
.step-title {
font-size: 16px;
font-weight: 500;
color: #333;
}
.save-btn {
padding: 8px 24px;
font-size: 14px;
border-radius: 4px;
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 0;
padding: 0 32px;
height: 44px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
}
.toolbar-btn {
width: 36px;
height: 36px;
padding: 0;
border-radius: 4px;
color: #666;
font-size: 16px;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: #f5f5f5;
color: #409EFF;
}
.format-icon {
font-size: 16px;
font-weight: 700;
font-family: Arial, sans-serif;
font-style: normal;
}
.format-icon.italic {
font-style: italic;
}
.format-icon.underline {
text-decoration: underline;
}
.format-icon.strike {
text-decoration: line-through;
}
.toolbar-divider {
width: 1px;
height: 24px;
background: #e8e8e8;
margin: 0 16px;
}
.word-count {
font-size: 12px;
color: #999;
padding: 0 12px;
min-width: 60px;
text-align: right;
}
.editor-content {
flex: 1;
overflow: hidden;
padding: 32px;
display: flex;
justify-content: center;
}
.editor-area {
width: 100%;
max-width: 840px;
height: 100%;
background: #fff;
border-radius: 8px;
padding: 40px;
overflow-y: auto;
outline: none;
font-size: 16px;
line-height: 1.8;
color: #333;
}
.editor-area:empty:before {
content: attr(data-placeholder);
color: #c0c4cc;
pointer-events: none;
}
.editor-area p {
margin: 16px 0;
}
.editor-area img {
max-width: 100%;
height: auto;
margin: 10px 0;
}
.editor-area a {
color: #409EFF;
text-decoration: underline;
}
.editor-area ul,
.editor-area ol {
margin: 16px 0;
padding-left: 32px;
}
.editor-area li {
margin: 8px 0;
}
</style>
</head>
<body>
<div id="app">
<div class="step-editor-container">
<div class="editor-header">
<div class="header-left">
<el-button type="text" icon="el-icon-back" @click="goBack" class="back-btn">
关闭
</el-button>
<span class="step-title">{{ stepTitle }}</span>
</div>
<div class="header-right">
<el-button type="primary" @click="saveContent" class="save-btn">
<i class="el-icon-check"></i>
保存
</el-button>
</div>
</div>
<div class="editor-toolbar">
<div class="toolbar-group">
<el-tooltip content="撤销" placement="top">
<el-button type="text" @click="formatText('undo')" class="toolbar-btn">
<i class="el-icon-refresh-left"></i>
</el-button>
</el-tooltip>
<el-tooltip content="重做" placement="top">
<el-button type="text" @click="formatText('redo')" class="toolbar-btn">
<i class="el-icon-refresh-right"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="导入文档" placement="top">
<el-button type="text" @click="importDocument" class="toolbar-btn">
<i class="el-icon-document"></i>
</el-button>
</el-tooltip>
<el-tooltip content="导入图片" placement="top">
<el-button type="text" @click="importImage" class="toolbar-btn">
<i class="el-icon-picture"></i>
</el-button>
</el-tooltip>
<el-tooltip content="插入链接" placement="top">
<el-button type="text" @click="insertLink" class="toolbar-btn">
<i class="el-icon-link"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="加粗" placement="top">
<el-button type="text" @click="formatText('bold')" class="toolbar-btn">
<span class="format-icon">B</span>
</el-button>
</el-tooltip>
<el-tooltip content="斜体" placement="top">
<el-button type="text" @click="formatText('italic')" class="toolbar-btn">
<span class="format-icon italic">I</span>
</el-button>
</el-tooltip>
<el-tooltip content="下划线" placement="top">
<el-button type="text" @click="formatText('underline')" class="toolbar-btn">
<span class="format-icon underline">U</span>
</el-button>
</el-tooltip>
<el-tooltip content="删除线" placement="top">
<el-button type="text" @click="formatText('strikeThrough')" class="toolbar-btn">
<span class="format-icon strike">S</span>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="上标" placement="top">
<el-button type="text" @click="formatText('superscript')" class="toolbar-btn">
<span class="format-icon"></span>
</el-button>
</el-tooltip>
<el-tooltip content="下标" placement="top">
<el-button type="text" @click="formatText('subscript')" class="toolbar-btn">
<span class="format-icon">x₂</span>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="左对齐" placement="top">
<el-button type="text" @click="formatText('justifyLeft')" class="toolbar-btn">
<i class="el-icon-s-unfold"></i>
</el-button>
</el-tooltip>
<el-tooltip content="居中对齐" placement="top">
<el-button type="text" @click="formatText('justifyCenter')" class="toolbar-btn">
<i class="el-icon-s-grid"></i>
</el-button>
</el-tooltip>
<el-tooltip content="右对齐" placement="top">
<el-button type="text" @click="formatText('justifyRight')" class="toolbar-btn">
<i class="el-icon-s-fold"></i>
</el-button>
</el-tooltip>
<el-tooltip content="两端对齐" placement="top">
<el-button type="text" @click="formatText('justifyFull')" class="toolbar-btn">
<i class="el-icon-menu"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="无序列表" placement="top">
<el-button type="text" @click="formatText('insertUnorderedList')" class="toolbar-btn">
<i class="el-icon-plus"></i>
</el-button>
</el-tooltip>
<el-tooltip content="有序列表" placement="top">
<el-button type="text" @click="formatText('insertOrderedList')" class="toolbar-btn">
<i class="el-icon-sort"></i>
</el-button>
</el-tooltip>
<el-tooltip content="减少缩进" placement="top">
<el-button type="text" @click="formatText('outdent')" class="toolbar-btn">
<i class="el-icon-back"></i>
</el-button>
</el-tooltip>
<el-tooltip content="增加缩进" placement="top">
<el-button type="text" @click="formatText('indent')" class="toolbar-btn">
<i class="el-icon-right"></i>
</el-button>
</el-tooltip>
</div>
<div class="toolbar-divider"></div>
<div class="toolbar-group">
<el-tooltip content="全屏" placement="top">
<el-button type="text" @click="toggleFullscreen" class="toolbar-btn">
<i :class="isFullscreen ? 'el-icon-crop' : 'el-icon-full-screen'"></i>
</el-button>
</el-tooltip>
<div class="word-count">
{{ wordCount }} 字
</div>
</div>
</div>
<div class="editor-content">
<div
ref="editor"
class="editor-area"
contenteditable="true"
:data-placeholder="placeholder"
@input="onInput"
@keydown="onKeydown"
@focus="onFocus"
@blur="onBlur"
>
</div>
</div>
<input
ref="fileInput"
type="file"
style="display: none"
@change="handleFileChange"
/>
</div>
</div>
<script src="https://unpkg.com/vue@2.6.12/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script src="https://unpkg.com/mammoth@1.6.0/mammoth.browser.min.js"></script>
<script>
new Vue({
el: '#app',
data() {
return {
stepTitle: '',
stepIndex: 0,
content: '',
placeholder: '在此输入内容...',
uploadType: '',
savedContent: {},
wordCount: 0,
isFullscreen: false
}
},
created() {
const urlParams = new URLSearchParams(window.location.search)
this.stepTitle = urlParams.get('title') || '编辑器'
this.stepIndex = parseInt(urlParams.get('index')) || 0
this.loadSavedContent()
},
methods: {
goBack() {
window.close()
},
formatText(command) {
document.execCommand(command, false, null)
this.$refs.editor.focus()
},
importDocument() {
this.uploadType = 'document'
this.$refs.fileInput.accept = '.txt,.md,.docx'
this.$refs.fileInput.click()
},
importImage() {
this.uploadType = 'image'
this.$refs.fileInput.accept = 'image/*'
this.$refs.fileInput.click()
},
handleFileChange(event) {
const file = event.target.files[0]
if (!file) return
if (this.uploadType === 'document') {
this.handleDocumentUpload(file)
} else if (this.uploadType === 'image') {
this.handleImageUpload(file)
}
event.target.value = ''
},
handleDocumentUpload(file) {
const fileName = file.name.toLowerCase()
const fileExt = fileName.substring(fileName.lastIndexOf('.'))
if (fileExt === '.docx') {
this.handleDocxUpload(file)
} else if (fileExt === '.txt' || fileExt === '.md') {
this.handleTextFileUpload(file)
} else {
this.$message.warning('不支持的文件格式,请使用 .txt、.md 或 .docx 文件')
}
},
handleDocxUpload(file) {
this.$message.warning('正在解析 Word 文档...')
const reader = new FileReader()
reader.onload = (e) => {
try {
const arrayBuffer = e.target.result
this.parseDocx(arrayBuffer)
} catch (error) {
console.error('解析 Word 文档失败:', error)
this.$message.error('解析 Word 文档失败,请将文档另存为 .txt 或 .md 格式后重试')
}
}
reader.readAsArrayBuffer(file)
},
async parseDocx(arrayBuffer) {
try {
const result = await mammoth.extractRawText({ arrayBuffer: arrayBuffer })
const text = result.value
this.insertText(text)
this.$message.success('Word 文档导入成功')
} catch (error) {
console.error('mammoth.js 解析失败:', error)
this.$message.error('解析 Word 文档失败')
}
},
handleTextFileUpload(file) {
const reader = new FileReader()
reader.onload = (e) => {
let text = e.target.result
if (!text || text.trim() === '') {
this.$message.warning('文档内容为空')
return
}
if (this.isGarbled(text)) {
this.tryGBKEncoding(file)
} else {
this.insertText(text)
this.$message.success('文档导入成功')
}
}
reader.readAsText(file, 'UTF-8')
},
isGarbled(text) {
const garbageChars = text.match(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g)
return garbageChars && garbageChars.length > text.length * 0.3
},
tryGBKEncoding(file) {
const reader = new FileReader()
reader.onload = (e) => {
const text = e.target.result
if (this.isGarbled(text)) {
this.$message.error('文档编码无法识别,请确保文档使用 UTF-8 或 GBK 编码')
} else {
this.insertText(text)
this.$message.success('文档导入成功')
}
}
reader.readAsText(file, 'GBK')
},
handleImageUpload(file) {
const reader = new FileReader()
reader.onload = (e) => {
const img = `<img src="${e.target.result}" style="max-width: 100%; height: auto; margin: 10px 0;" />`
this.insertHTML(img)
this.$message.success('图片导入成功')
}
reader.readAsDataURL(file)
},
insertLink() {
this.$prompt('请输入链接地址', '插入链接', {
confirmButtonText: '确定',
cancelButtonText: '取消'
}).then(({ value }) => {
if (value) {
const link = `<a href="${value}" target="_blank" style="color: #409EFF; text-decoration: underline;">${value}</a>`
this.insertHTML(link)
}
})
},
insertText(text) {
const editor = this.$refs.editor
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const textNode = document.createTextNode(text)
range.insertNode(textNode)
} else {
if (editor.innerHTML.trim() === '') {
editor.innerHTML = `<p>${text}</p>`
} else {
editor.innerHTML += `<p>${text}</p>`
}
}
this.$nextTick(() => {
this.updateWordCount()
})
},
insertHTML(html) {
const editor = this.$refs.editor
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const div = document.createElement('div')
div.innerHTML = html
range.insertNode(div)
} else {
if (editor.innerHTML.trim() === '') {
editor.innerHTML = `<p>${html}</p>`
} else {
editor.innerHTML += `<p>${html}</p>`
}
}
this.$nextTick(() => {
this.updateWordCount()
})
},
onInput() {
this.content = this.$refs.editor.innerHTML
this.updateWordCount()
},
updateWordCount() {
const text = this.$refs.editor.innerText || ''
this.wordCount = text.replace(/\s+/g, '').length
},
toggleFullscreen() {
this.isFullscreen = !this.isFullscreen
const container = document.querySelector('.step-editor-container')
if (this.isFullscreen) {
if (container.requestFullscreen) {
container.requestFullscreen()
} else if (container.webkitRequestFullscreen) {
container.webkitRequestFullscreen()
} else if (container.msRequestFullscreen) {
container.msRequestFullscreen()
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen()
} else if (document.msExitFullscreen) {
document.msExitFullscreen()
}
}
},
onFocus() {
const editor = this.$refs.editor
if (editor.innerText.trim() === '' && editor.innerHTML.trim() === '') {
editor.innerHTML = ''
}
},
onBlur() {
const editor = this.$refs.editor
if (editor.innerHTML.trim() === '' || editor.innerText.trim() === '') {
editor.innerHTML = ''
}
},
onKeydown(event) {
if (event.ctrlKey || event.metaKey) {
if (event.key === 's') {
event.preventDefault()
this.saveContent()
}
}
},
saveContent() {
this.content = this.$refs.editor.innerHTML
const savedData = JSON.parse(localStorage.getItem('stepEditorContent') || '{}')
savedData[`step_${this.stepIndex}`] = {
content: this.content,
updateTime: new Date().toISOString()
}
localStorage.setItem('stepEditorContent', JSON.stringify(savedData))
this.$message.success('保存成功')
},
loadSavedContent() {
const savedData = JSON.parse(localStorage.getItem('stepEditorContent') || '{}')
const stepData = savedData[`step_${this.stepIndex}`]
if (stepData && stepData.content) {
this.$nextTick(() => {
this.$refs.editor.innerHTML = stepData.content
this.content = stepData.content
this.updateWordCount()
})
}
}
}
})
</script>
</body>
</html>

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

@ -21,31 +21,34 @@ export function getLib(id) {
})
}
// 新增平台模版库
export function addLib(data) {
// 新增平台模版库(可选 params.roomId 用于对象级操作日志)
export function addLib(data, params = {}) {
return request({
url: '/system/lib/add',
method: 'post',
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {},
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 修改平台模版库
export function updateLib(data) {
// 修改平台模版库(可选 params.roomId)
export function updateLib(data, params = {}) {
return request({
url: '/system/lib',
method: 'put',
data: data
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}
// 删除平台模版库
export function delLib(id) {
// 删除平台模版库(可选 params.roomId)
export function delLib(id, params = {}) {
return request({
url: '/system/lib/' + id,
method: 'delete'
method: 'delete',
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}

23
ruoyi-ui/src/api/system/objectLog.js

@ -0,0 +1,23 @@
import request from '@/utils/request'
/**
* 对象级操作日志分页查询按房间操作人类型等
*/
export function listObjectLog(params) {
return request({
url: '/system/object-log/list',
method: 'get',
params
})
}
/**
* 回滚到指定操作数据库 + Redis 同步
*/
export function rollbackObjectLog(id) {
return request({
url: '/system/object-log/rollback',
method: 'post',
params: { id }
})
}

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({

160
ruoyi-ui/src/api/system/routes.js

@ -17,28 +17,170 @@ export function getRoutes(id) {
})
}
// 新增实体部署与航线
export function addRoutes(data) {
// 新增实体部署与航线(可选 params.roomId 用于对象级操作日志按房间记录)
export function addRoutes(data, params = {}) {
return request({
url: '/system/routes',
method: 'post',
data: data
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}
// 修改实体部署与航线
export function updateRoutes(data) {
// 修改实体部署与航线(可选 params.roomId;禁用防重复提交)
export function updateRoutes(data, params = {}) {
return request({
url: '/system/routes',
method: 'put',
data: data
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {},
headers: { repeatSubmit: false }
})
}
// 删除实体部署与航线
export function delRoutes(id) {
// 删除实体部署与航线(可选 params.roomId)
export function delRoutes(id, params = {}) {
return request({
url: '/system/routes/' + id,
method: 'delete'
method: 'delete',
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}
// 保存平台样式
export function savePlatformStyle(data) {
return request({
url: '/system/routes/savePlatformStyle',
method: 'post',
data: data
})
}
// 获取平台样式
export function getPlatformStyle(query) {
return request({
url: '/system/routes/getPlatformStyle',
method: 'get',
params: query
})
}
// 删除独立平台图标样式(按实例删除,避免删除图标后残留威力区/探测区)
export function deletePlatformIconStyle(roomId, platformIconInstanceId) {
return request({
url: '/system/routes/platformIconStyle',
method: 'delete',
params: { roomId, platformIconInstanceId }
})
}
// 保存4T数据到Redis(禁用防重复提交,因拖拽/调整大小可能快速连续触发保存)
export function save4TData(data) {
return request({
url: '/system/routes/save4TData',
method: 'post',
data,
headers: { repeatSubmit: false }
})
}
// 从Redis获取4T数据
export function get4TData(params) {
return request({
url: '/system/routes/get4TData',
method: 'get',
params
})
}
// 截图展示:同一路径 GET 读 / POST 写(Redis),与 4T 同权限
export function saveScreenshotGalleryData(data) {
return request({
url: '/system/routes/roomScreenshotGallery',
method: 'post',
data,
headers: { repeatSubmit: false }
})
}
export function getScreenshotGalleryData(params) {
return request({
url: '/system/routes/roomScreenshotGallery',
method: 'get',
params
})
}
// 保存六步法任务页数据到 Redis(背景、图标、文本框)
export function saveTaskPageData(data) {
return request({
url: '/system/routes/saveTaskPageData',
method: 'post',
data,
headers: { repeatSubmit: false }
})
}
// 从 Redis 获取六步法任务页数据
export function getTaskPageData(params) {
return request({
url: '/system/routes/getTaskPageData',
method: 'get',
params
})
}
// 保存六步法全部数据到 Redis(任务页、理解、后五步、背景、多页等)
export function saveSixStepsData(data) {
return request({
url: '/system/routes/saveSixStepsData',
method: 'post',
data,
headers: { repeatSubmit: false }
})
}
// 从 Redis 获取六步法全部数据
export function getSixStepsData(params) {
return request({
url: '/system/routes/getSixStepsData',
method: 'get',
params
})
}
// 获取导弹发射参数(Redis:房间+航线+平台为 key)
export function getMissileParams(params) {
return request({
url: '/system/routes/missile-params',
method: 'get',
params
})
}
// 保存导弹发射参数到 Redis
export function saveMissileParams(data) {
return request({
url: '/system/routes/missile-params',
method: 'post',
data
})
}
// 删除指定导弹发射参数(按数组索引删除)
export function deleteMissileParams(params) {
return request({
url: '/system/routes/missile-params',
method: 'delete',
params
})
}
// 批量更新导弹发射位置(航线编辑后,根据新航点重算平台位置)
export function updateMissilePositions(data) {
return request({
url: '/system/routes/missile-params/positions',
method: 'put',
data
})
}

53
ruoyi-ui/src/api/system/timeline.js

@ -0,0 +1,53 @@
import request from '@/utils/request'
export function listTimelineSegments(query) {
return request({
url: '/system/timeline/list',
method: 'get',
params: query
})
}
export function getTimelineSegmentsByRoomId(roomId) {
return request({
url: '/system/timeline/listByRoomId/' + roomId,
method: 'get'
})
}
export function getTimelineSegment(id) {
return request({
url: '/system/timeline/' + id,
method: 'get'
})
}
export function addTimelineSegment(data) {
return request({
url: '/system/timeline',
method: 'post',
data: data
})
}
export function updateTimelineSegment(data) {
return request({
url: '/system/timeline',
method: 'put',
data: data
})
}
export function delTimelineSegment(id) {
return request({
url: '/system/timeline/' + id,
method: 'delete'
})
}
export function delTimelineSegmentsByRoomId(roomId) {
return request({
url: '/system/timeline/room/' + roomId,
method: 'delete'
})
}

22
ruoyi-ui/src/api/system/waypoints.js

@ -17,28 +17,32 @@ export function getWaypoints(id) {
})
}
// 新增航线具体航点明细
export function addWaypoints(data) {
// 新增航线具体航点明细(可选 params.roomId 用于对象级操作日志)
export function addWaypoints(data, params = {}) {
return request({
url: '/system/waypoints',
method: 'post',
data: data
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}
// 修改航线具体航点明细
export function updateWaypoints(data) {
// 修改航线具体航点明细(可选 params.roomId;禁用防重复提交)
export function updateWaypoints(data, params = {}) {
return request({
url: '/system/waypoints',
method: 'put',
data: data
data: data,
params: params.roomId != null ? { roomId: params.roomId } : {},
headers: { repeatSubmit: false }
})
}
// 删除航线具体航点明细
export function delWaypoints(id) {
// 删除航线具体航点明细(可选 params.roomId)
export function delWaypoints(id, params = {}) {
return request({
url: '/system/waypoints/' + id,
method: 'delete'
method: 'delete',
params: params.roomId != null ? { roomId: params.roomId } : {}
})
}

98
ruoyi-ui/src/api/system/whiteboard.js

@ -0,0 +1,98 @@
import request from '@/utils/request'
/** 获取房间下所有白板列表 */
export function listWhiteboards(roomId) {
return request({
url: `/room/${roomId}/whiteboards`,
method: 'get'
})
}
/** 获取单个白板详情 */
export function getWhiteboard(roomId, whiteboardId) {
return request({
url: `/room/${roomId}/whiteboard/${whiteboardId}`,
method: 'get'
})
}
/** 创建白板 */
export function createWhiteboard(roomId, data) {
return request({
url: `/room/${roomId}/whiteboard`,
method: 'post',
data: data || {}
})
}
/** 更新白板 */
export function updateWhiteboard(roomId, whiteboardId, data) {
return request({
url: `/room/${roomId}/whiteboard/${whiteboardId}`,
method: 'put',
data: data || {}
})
}
/** 删除白板 */
export function deleteWhiteboard(roomId, whiteboardId) {
return request({
url: `/room/${roomId}/whiteboard/${whiteboardId}`,
method: 'delete'
})
}
/** 保存白板平台样式(Redis key: whiteboard:scheme:{schemeId}:platform:{platformInstanceId}:style) */
export function saveWhiteboardPlatformStyle(data) {
return request({
url: '/room/whiteboard/platform/style',
method: 'post',
data: data || {}
})
}
/** 获取白板平台样式 */
export function getWhiteboardPlatformStyle(params) {
return request({
url: '/room/whiteboard/platform/style',
method: 'get',
params: params || {}
})
}
/** 删除白板平台样式 */
export function deleteWhiteboardPlatformStyle(params) {
return request({
url: '/room/whiteboard/platform/style',
method: 'delete',
params: params || {}
})
}
/** 获取当前用户协同资料(显示名、头像) */
export function getRoomUserProfile() {
return request({
url: '/room/user/profile',
method: 'get'
})
}
/** 更新当前用户协同显示名 */
export function updateRoomUserProfile(data) {
return request({
url: '/room/user/profile',
method: 'put',
data: data || {}
})
}
/** 上传当前用户协同头像(独立目录) */
export function uploadRoomUserAvatar(file) {
const formData = new FormData()
formData.append('avatarfile', file)
return request({
url: '/room/user/profile/avatar',
method: 'post',
data: formData
})
}

1
ruoyi-ui/src/assets/icons/svg/T.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1772160025231" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2763" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M866.56 70.72v123.84h-287.04v758.4h-136.32V194.56H157.44V70.72h709.12z" p-id="2764"></path></svg>

After

Width:  |  Height:  |  Size: 430 B

1
ruoyi-ui/src/assets/icons/svg/shujukapian.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1772159974776" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1624" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M880.96 64h-579.84c-43.648 0-79.04 35.392-79.04 79.04v105.408a79.04 79.04 0 0 0-79.04 79.04v105.408h-26.368A52.736 52.736 0 0 0 64 485.632v421.696c0 29.056 23.616 52.672 52.672 52.672h632.512a52.672 52.672 0 0 0 52.736-52.672v-131.84c43.648 0 79.04-35.328 79.04-79.04V591.104A79.04 79.04 0 0 0 960 512V143.04A79.04 79.04 0 0 0 880.96 64zM116.672 485.632h632.512v112.576H116.672V485.632z m0 421.696V650.88h632.512v256.448H116.672z m711.488-210.816a26.368 26.368 0 0 1-26.304 26.304V485.632a52.736 52.736 0 0 0-52.672-52.736H195.776V327.488c0-14.528 11.776-26.304 26.304-26.304h579.84c14.528 0 26.304 11.776 26.368 26.304v369.024h-0.128zM907.328 512a26.368 26.368 0 0 1-26.368 26.368V327.488c0-43.648-35.328-79.04-78.976-79.04H274.88V143.104c0-14.592 11.776-26.368 26.304-26.368h579.712c14.592 0 26.432 11.776 26.432 26.368V512h-0.064z" fill="#000000" p-id="1625"></path><path d="M448 768a32 32 0 1 0 64 0 32 32 0 0 0-64 0z m96 0a32 32 0 1 0 64 0 32 32 0 0 0-64 0z m96 0a32 32 0 1 0 64 0 32 32 0 0 0-64 0z" fill="#000000" p-id="1626"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
ruoyi-ui/src/assets/images/missile.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

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

2
ruoyi-ui/src/assets/styles/element-variables.scss

@ -4,7 +4,7 @@
**/
/* theme color */
$--color-primary: #1890ff;
$--color-primary: #165dff;
$--color-success: #13ce66;
$--color-warning: #ffba00;
$--color-danger: #ff4949;

6
ruoyi-ui/src/assets/styles/ruoyi.scss

@ -81,6 +81,12 @@
margin-top: 6vh !important;
}
/* 全局弹窗:取消背景变暗,点击遮罩不影响地图操作(遮罩透明且不拦截鼠标事件) */
.v-modal {
background-color: transparent !important;
pointer-events: none !important;
}
.el-dialog__wrapper.scrollbar .el-dialog .el-dialog__body {
overflow: auto;
overflow-x: hidden;

2
ruoyi-ui/src/assets/styles/variables.scss

@ -1,6 +1,6 @@
// base color
$blue:#324157;
$light-blue:#3A71A8;
$light-blue:#165dff;
$red:#C03639;
$pink: #E65D6E;
$green: #30B08F;

4
ruoyi-ui/src/components/ThemePicker/index.vue

@ -1,14 +1,14 @@
<template>
<el-color-picker
v-model="theme"
:predefine="['#409EFF', '#1890ff', '#304156','#212121','#11a983', '#13c2c2', '#6959CD', '#f5222d', ]"
:predefine="['#165dff', '#304156','#212121','#11a983', '#13c2c2', '#6959CD', '#f5222d', ]"
class="theme-picker"
popper-class="theme-picker-dropdown"
/>
</template>
<script>
const ORIGINAL_THEME = '#409EFF' // default color
const ORIGINAL_THEME = '#165dff' // default color
export default {
data() {

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

@ -21,7 +21,11 @@ export default {
importATO: 'Import ATO',
importLayer: 'Import Layer',
importRoute: 'Import Route',
export: 'Export'
importPlatformGraphics: 'Import Platform Graphics',
export: 'Export',
exportRoute: 'Export Routes',
exportPlan: 'Export Plan',
exportPlatformGraphics: 'Export Platform Graphics'
},
edit: {
routeEdit: 'Route Edit',
@ -49,14 +53,10 @@ export default {
},
airspace: {
powerZone: 'Power Zone',
threatZone: 'Threat Zone'
},
tools: {
routeCalculation: 'Route Calculation',
conflictDisplay: 'Conflict Display',
dataMaterials: 'Data Materials',
coordinateConversion: 'Coordinate Conversion'
threatZone: 'Threat Zone',
generateAirspace: 'Generate Airspace'
},
options: {
settings: 'Settings',
pageLayout: 'Page Layout',
@ -71,6 +71,13 @@ export default {
systemDescription: 'System Description',
language: 'Language'
},
tools: {
routeCalculation: 'Route Calculation',
conflictDisplay: 'Conflict Display',
dataMaterials: 'Data Materials',
coordinateConversion: 'Coordinate Conversion',
generateGanttChart: 'Generate Gantt Chart'
},
favorites: {
layerFavorites: 'Layer Favorites',
routeFavorites: 'Route Favorites'
@ -114,9 +121,15 @@ export default {
conflictTime: 'Conflict Time',
conflictPosition: 'Conflict Position',
viewDetails: 'View Details',
locate: 'Locate',
resolveConflict: 'Resolve Conflict',
noConflict: 'No Conflict',
noMatchFilter: 'No matches for current filter',
recheck: 'Recheck',
conflictFilter: 'Type',
conflictTypeAll: 'All',
conflictTypeTime: 'Time',
conflictTypeSpace: 'Space',
air: 'Air',
sea: 'Sea',
ground: 'Ground'
@ -143,11 +156,55 @@ export default {
rollbackConfirmText: 'Are you sure you want to rollback to the selected operation?',
rollbackWarning: 'This operation will undo this operation and all subsequent changes, cannot be recovered!',
confirmRollback: 'Confirm Rollback',
groupChat: 'Group Chat - Online Members Communication',
groupChat: 'Group Chat',
privateChat: 'Private Chat',
selectMember: 'Select',
selectMemberToChat: 'Select a member to start private chat',
selectMemberFirst: 'Please select a member first',
onlineCount: ' people online',
inputMessage: 'Please enter message...',
send: 'Send',
pleaseInputMessage: 'Please enter message content',
operationRollbackSuccess: 'Operation rollback successful'
operationRollbackSuccess: 'Operation rollback successful',
noLogs: 'No operation logs',
selectPrivateContact: 'Select private chat contact',
changePrivateContact: 'Change contact',
noChatableMembers: 'No members available for private chat',
confirmPickContact: 'OK',
memberStatusOnline: 'Online',
memberStatusOffline: 'Offline'
},
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'
}
}

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

@ -21,7 +21,11 @@ export default {
importATO: '导入ATO',
importLayer: '导入图层',
importRoute: '导入航线',
export: '导出'
importPlatformGraphics: '导入平台图形',
export: '导出',
exportRoute: '导出航线',
exportPlan: '导出计划',
exportPlatformGraphics: '导出平台图形'
},
edit: {
routeEdit: '航线编辑',
@ -49,14 +53,10 @@ export default {
},
airspace: {
powerZone: '威力区',
threatZone: '威胁区'
},
tools: {
routeCalculation: '航线计算',
conflictDisplay: '冲突显示',
dataMaterials: '数据资料',
coordinateConversion: '坐标换算'
threatZone: '威胁区',
generateAirspace: '生成空域'
},
options: {
settings: '设置',
pageLayout: '页面布局',
@ -71,6 +71,13 @@ export default {
systemDescription: '系统说明',
language: '语言'
},
tools: {
routeCalculation: '航线计算',
conflictDisplay: '冲突显示',
dataMaterials: '数据资料',
coordinateConversion: '坐标转换',
generateGanttChart: '生成甘特图'
},
favorites: {
layerFavorites: '图层收藏',
routeFavorites: '航线收藏'
@ -114,9 +121,15 @@ export default {
conflictTime: '冲突时间',
conflictPosition: '冲突位置',
viewDetails: '查看详情',
locate: '定位',
resolveConflict: '解决冲突',
noConflict: '暂无冲突',
noMatchFilter: '当前筛选无匹配项',
recheck: '重新检测',
conflictFilter: '类型筛选',
conflictTypeAll: '全部',
conflictTypeTime: '时间',
conflictTypeSpace: '空间',
air: '空中',
sea: '海上',
ground: '地面'
@ -143,11 +156,55 @@ export default {
rollbackConfirmText: '确定要回滚到所选操作吗?',
rollbackWarning: '此操作将撤销该操作及其后的所有更改,不可恢复!',
confirmRollback: '确定回滚',
groupChat: '群聊 - 在线成员交流',
groupChat: '群聊',
privateChat: '私聊',
selectMember: '选择对象',
selectMemberToChat: '选择成员开始私聊',
selectMemberFirst: '请先选择要私聊的成员',
onlineCount: '人在线',
inputMessage: '请输入消息...',
send: '发送',
pleaseInputMessage: '请输入消息内容',
operationRollbackSuccess: '操作回滚成功'
operationRollbackSuccess: '操作回滚成功',
noLogs: '暂无操作日志',
selectPrivateContact: '选择私聊联系人',
changePrivateContact: '更换联系人',
noChatableMembers: '暂无可私聊的成员',
confirmPickContact: '确定',
memberStatusOnline: '在线',
memberStatusOffline: '离线'
},
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: '生成失败,请检查坐标与参数'
}
}

2
ruoyi-ui/src/layout/components/Settings/index.vue

@ -292,7 +292,7 @@ export default {
height: 100%;
padding-top: 15px;
padding-left: 24px;
color: #1890ff;
color: #165dff;
font-weight: 700;
font-size: 14px;
}

8
ruoyi-ui/src/layout/components/TagsView/index.vue

@ -223,7 +223,13 @@ export default {
this.left = left
}
this.top = e.clientY
const padding = 12
const menuHeight = 250
const winH = window.innerHeight
let top = e.clientY
if (top + menuHeight + padding > winH) top = winH - menuHeight - padding
if (top < padding) top = padding
this.top = top
this.visible = true
this.selectedTag = tag
},

4
ruoyi-ui/src/main.js

@ -78,6 +78,10 @@ Vue.use(Element, {
size: Cookies.get('size') || 'medium' // set element-ui default size
})
// 全局 el-dialog 可拖动(必须在 Element 之后注册)
import dialogDrag from './plugins/dialogDrag'
Vue.use(dialogDrag)
Vue.config.productionTip = false
new Vue({

181
ruoyi-ui/src/plugins/dialogDrag.js

@ -0,0 +1,181 @@
/**
* 全局弹窗可拖动覆盖项目内所有弹窗
* 1. 弹窗出现时直接给标题栏绑定拖动MutationObserver + 延迟扫描
* 2. document 捕获阶段 mousedown 兜底确保点击标题栏一定能拖
* 支持el-dialogel-message-box($prompt/confirm/alert)自定义 .dialog-content
*/
let inited = false
function startDrag(e, container) {
if (!container) return
e.preventDefault()
e.stopPropagation()
const prevUserSelect = document.body.style.userSelect
document.body.style.userSelect = 'none'
let startX = e.clientX
let startY = e.clientY
const rect = container.getBoundingClientRect()
let left = rect.left
let top = rect.top
container.style.position = 'fixed'
container.style.left = left + 'px'
container.style.top = top + 'px'
container.style.margin = '0'
container.style.transform = 'none'
/* 锁定宽高,避免拖拽标题栏时弹窗尺寸被改变(只做位移) */
container.style.width = rect.width + 'px'
container.style.height = rect.height + 'px'
container.style.boxSizing = 'border-box'
const onMouseMove = (e2) => {
e2.preventDefault()
const dx = e2.clientX - startX
const dy = e2.clientY - startY
left += dx
top += dy
startX = e2.clientX
startY = e2.clientY
container.style.left = left + 'px'
container.style.top = top + 'px'
}
const onMouseUp = () => {
document.body.style.userSelect = prevUserSelect
document.removeEventListener('mousemove', onMouseMove, true)
document.removeEventListener('mouseup', onMouseUp, true)
}
document.addEventListener('mousemove', onMouseMove, true)
document.addEventListener('mouseup', onMouseUp, true)
}
/** el-dialog 仅由此处理:document 捕获阶段拦截标题栏 mousedown,避免双重绑定导致乱动/不动 */
function onDocumentMouseDown(e) {
if (e.button !== 0) return
if (e.target.closest('.el-dialog__headerbtn') || e.target.closest('.el-message-box__headerbtn') || e.target.closest('.close-btn')) return
/* 右下角改尺寸手柄:不触发拖拽,只做缩放 */
if (e.target.closest('.gantt-dialog-resize-handle')) return
const elHeader = e.target.closest('.el-dialog__header')
if (elHeader) {
const wrapper = elHeader.closest('.el-dialog__wrapper')
if (wrapper) {
startDrag(e, wrapper)
return
}
}
const msgHeader = e.target.closest('.el-message-box__header')
if (msgHeader) {
const wrapper = msgHeader.closest('.el-message-box__wrapper')
if (wrapper) {
startDrag(e, wrapper)
return
}
}
const customHeader = e.target.closest('.dialog-header')
if (customHeader && !customHeader.closest('.el-dialog__wrapper')) {
const content = customHeader.closest('.dialog-content')
if (content) {
startDrag(e, content)
}
}
}
function bindMessageBox(wrapper) {
if (wrapper.getAttribute('data-dialog-drag-bound') === '1') return
const header = wrapper.querySelector('.el-message-box__header')
if (!header) return
wrapper.setAttribute('data-dialog-drag-bound', '1')
header.style.cursor = 'move'
header.addEventListener('mousedown', (e) => {
if (e.button !== 0 || e.target.closest('.el-message-box__headerbtn')) return
startDrag(e, wrapper)
})
}
function bindCustomDialog(content) {
if (content.getAttribute('data-dialog-drag-bound') === '1') return
const header = content.querySelector('.dialog-header')
if (!header) return
content.setAttribute('data-dialog-drag-bound', '1')
header.style.cursor = 'move'
header.addEventListener('mousedown', (e) => {
if (e.button !== 0 || e.target.closest('.close-btn')) return
startDrag(e, content)
})
}
function scanAndBind() {
if (typeof document === 'undefined' || !document.body) return
document.querySelectorAll('.el-message-box__wrapper').forEach(bindMessageBox)
document.querySelectorAll('.dialog-content').forEach((content) => {
if (!content.closest('.el-dialog__wrapper') && content.querySelector('.dialog-header')) {
bindCustomDialog(content)
}
})
}
/** 弹窗关闭时清除我们设置的 left/top,避免下次打开从错误位置“瞬移”到中间 */
function resetClosedDialogPositions() {
if (typeof document === 'undefined' || !document.body) return
document.querySelectorAll('.el-dialog__wrapper').forEach((wrapper) => {
const style = window.getComputedStyle(wrapper)
if (style.display === 'none' || style.visibility === 'hidden') {
wrapper.style.left = ''
wrapper.style.top = ''
wrapper.style.position = ''
wrapper.style.margin = ''
wrapper.style.transform = ''
}
})
document.querySelectorAll('.el-message-box__wrapper').forEach((wrapper) => {
const style = window.getComputedStyle(wrapper)
if (style.display === 'none' || style.visibility === 'hidden') {
wrapper.style.left = ''
wrapper.style.top = ''
wrapper.style.position = ''
wrapper.style.margin = ''
wrapper.style.transform = ''
}
})
}
function initDialogDrag(Vue) {
if (typeof document === 'undefined' || !document.body || inited) return
inited = true
const style = document.createElement('style')
style.textContent = [
'.el-dialog__header { cursor: move !important; }',
'.el-dialog__header .el-dialog__headerbtn { cursor: pointer !important; }',
'.el-message-box__header { cursor: move !important; }',
'.el-message-box__header .el-message-box__headerbtn { cursor: pointer !important; }',
'.dialog-header { cursor: move !important; }',
'.dialog-header .close-btn { cursor: pointer !important; }'
].join(' ')
document.head.appendChild(style)
scanAndBind()
document.addEventListener('mousedown', onDocumentMouseDown, true)
const scheduleScan = () => {
resetClosedDialogPositions()
if (Vue && typeof Vue.nextTick === 'function') Vue.nextTick(scanAndBind)
else scanAndBind()
setTimeout(scanAndBind, 0)
setTimeout(scanAndBind, 50)
setTimeout(scanAndBind, 150)
setTimeout(scanAndBind, 400)
}
const observer = new MutationObserver(scheduleScan)
observer.observe(document.body, { childList: true, subtree: true })
if (Vue && typeof Vue.nextTick === 'function') Vue.nextTick(scanAndBind)
setTimeout(scanAndBind, 200)
setTimeout(scanAndBind, 500)
setTimeout(scanAndBind, 1200)
}
export default {
install(Vue) {
if (typeof document === 'undefined' || !document.body) return
initDialogDrag(Vue)
}
}

9
ruoyi-ui/src/router/index.js

@ -58,15 +58,14 @@ export const constantRoutes = [
component: () => import('@/views/childRoom'),
hidden: true
},
// 六步法编辑器
{
path: '/stepEditor',
component: () => import('@/views/childRoom/StepEditor'),
path: '/cesiumMap',
component: () => import('@/views/cesiumMap'),
hidden: true
},
{
path: '/cesiumMap',
component: () => import('@/views/cesiumMap'),
path: '/ganttChart',
component: () => import('@/views/ganttChart'),
hidden: true
},
{

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 = []

2
ruoyi-ui/src/store/modules/settings.js

@ -6,7 +6,7 @@ const { sideTheme, showSettings, navType, tagsView, tagsIcon, fixedHeader, sideb
const storageSetting = JSON.parse(localStorage.getItem('layout-setting')) || ''
const state = {
title: '',
theme: storageSetting.theme || '#409EFF',
theme: storageSetting.theme || '#165dff',
sideTheme: storageSetting.sideTheme || sideTheme,
showSettings: showSettings,
navType: storageSetting.navType === undefined ? navType : storageSetting.navType,

569
ruoyi-ui/src/utils/conflictDetection.js

@ -0,0 +1,569 @@
/**
* 冲突检测工具时间冲突空间冲突
* - 时间航线时间窗重叠无法按时到达提前到达盘旋时间不足资源占用缓冲
* - 空间航迹最小间隔摆放平台距离过小禁限区入侵考虑高度带Safety Column 间距
*/
/** 冲突类型 */
export const CONFLICT_TYPE = {
TIME: 'time',
SPACE: 'space'
}
/** 默认冲突配置(可由界面配置覆盖) */
export const defaultConflictConfig = {
// 时间
timeWindowOverlapMinutes: 0, // 时间窗重叠判定:两航线时间窗重叠即报(0=任意重叠)
resourceBufferMinutes: 0, // 资源占用前后缓冲(分钟)
/** 到达时间容差(秒):早于或晚于计划在此秒数内不报冲突,超出才报 */
timeToleranceSeconds: 5,
/** 速度容差(km/h):与所需/建议速度差在此范围内不报冲突,超出才报 */
speedToleranceKmh: 20,
// 空间
minTrackSeparationMeters: 5000, // 推演中两平台航迹最小间隔(米)
minPlatformPlacementMeters: 3000, // 摆放平台图标最小间距(米)
trackSampleStepMinutes: 1, // 航迹采样步长(分钟)
restrictedZoneNameKeywords: ['禁限', '禁区', '限制区'], // 空域名称含这些词视为禁限区
useRestrictedAltitudeBand: true, // 禁限区是否考虑高度带(若空域有 altMin/altMax)
/** 隐藏圆柱体(Safety Column):水平半径 R、垂直高度差阈值 H、同点先后时间阈值 ΔT(秒) */
safetyColumnRadiusMeters: 10000, // R:单柱水平半径(米)
safetyColumnHeightMeters: 300, // H:两机高度差小于此值且水平重叠时视为垂直方向不安全(米)
safetyColumnTimeThresholdSeconds: 60, // ΔT:先后经过同一位置的时间间隔小于此值报时间类冲突(秒)
/** 空间重叠采样步长(分钟),默认 0.5 即 30 秒 */
safetyColumnSpatialStepMinutes: 0.5,
/** 同点先后判定:时间维采样步长(分钟),默认 5 秒 */
safetyColumnTemporalStepMinutes: 5 / 60,
/** 判定「同一物理位置」的网格:经纬度量化(度),约百米量级 */
safetyColumnTemporalGridLngDeg: 0.001,
safetyColumnTemporalGridLatDeg: 0.001,
safetyColumnTemporalGridAltMeters: 100
}
/**
* 计算两点地表距离近似球面
*/
export function distanceMeters(lng1, lat1, alt1, lng2, lat2, alt2) {
const R = 6371000 // 地球半径米
const toRad = x => (x * Math.PI) / 180
const φ1 = toRad(lat1)
const φ2 = toRad(lat2)
const Δφ = toRad(lat2 - lat1)
const Δλ = toRad(lng2 - lng1)
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
const horizontal = R * c
const dAlt = (alt2 != null ? Number(alt2) : 0) - (alt1 != null ? Number(alt1) : 0)
return Math.sqrt(horizontal * horizontal + dAlt * dAlt)
}
/**
* 两段时间段是否重叠 [s1,e1] [s2,e2]分钟数
*/
export function timeRangesOverlap(s1, e1, s2, e2, bufferMinutes = 0) {
const a1 = s1 - bufferMinutes
const b1 = e1 + bufferMinutes
const a2 = s2 - bufferMinutes
const b2 = e2 + bufferMinutes
return !(b1 < a2 || b2 < a1)
}
/**
* 时间冲突航线时间窗重叠
* routes: [{ id, name, waypoints: [{ startTime }] }]waypoints 需含 startTime K+00:10
* waypointStartTimeToMinutes: (startTimeStr) => number
*/
export function detectTimeWindowOverlap(routes, waypointStartTimeToMinutes, config = {}) {
const buffer = config.resourceBufferMinutes != null ? config.resourceBufferMinutes : defaultConflictConfig.resourceBufferMinutes
const list = []
const timeRanges = routes.map(r => {
if (!r.waypoints || r.waypoints.length === 0) return { routeId: r.id, routeName: r.name || `航线${r.id}`, min: 0, max: 0 }
const minutes = r.waypoints.map(w => waypointStartTimeToMinutes(w.startTime)).filter(m => Number.isFinite(m))
const min = minutes.length ? Math.min(...minutes) : 0
const max = minutes.length ? Math.max(...minutes) : 0
return { routeId: r.id, routeName: r.name || `航线${r.id}`, min, max }
})
for (let i = 0; i < timeRanges.length; i++) {
for (let j = i + 1; j < timeRanges.length; j++) {
const a = timeRanges[i]
const b = timeRanges[j]
if (a.min === a.max && b.min === b.max) continue
if (timeRangesOverlap(a.min, a.max, b.min, b.max, buffer)) {
list.push({
type: CONFLICT_TYPE.TIME,
subType: 'time_window_overlap',
title: '航线时间窗重叠',
routeIds: [a.routeId, b.routeId],
routeNames: [a.routeName, b.routeName],
time: `${formatKLabel(a.min)}${formatKLabel(a.max)}${formatKLabel(b.min)}${formatKLabel(b.max)} 重叠`,
suggestion: '建议错开两条航线的计划时间窗,或为资源设置缓冲时间后再检测。'
})
}
}
}
return list
}
export function formatMinutes(m) {
const h = Math.floor(Math.abs(m) / 60)
const min = Math.floor(Math.abs(m) % 60)
const sign = m >= 0 ? '+' : '-'
return `${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`
}
/** 展示用完整 K 时标签:K+00:10(formatMinutes 已含 ±,勿再拼接「K+」前缀以免出现 K++) */
export function formatKLabel(m) {
return `K${formatMinutes(m)}`
}
/**
* 地表水平距离不含高度
*/
export function horizontalDistanceMeters(lng1, lat1, lng2, lat2) {
const R = 6371000
const toRad = x => (x * Math.PI) / 180
const φ1 = toRad(lat1)
const φ2 = toRad(lat2)
const Δφ = toRad(lat2 - lat1)
const Δλ = toRad(lng2 - lng1)
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
/**
* Safety Column 空间冲突同一时刻 t水平距离 < 2R |Δ高度| < H
* getSpeedKmhAtMinutes可选(routeId, minutesFromK) => number用于给出可执行的航速建议
*/
export function detectSafetyColumnSpatial(
routeIds,
minMinutes,
maxMinutes,
getPositionAtMinutesFromK,
config = {},
routeNamesById = {},
canPairFn = null,
getSpeedKmhAtMinutes = null
) {
const R = config.safetyColumnRadiusMeters != null ? Number(config.safetyColumnRadiusMeters) : defaultConflictConfig.safetyColumnRadiusMeters
const H = config.safetyColumnHeightMeters != null ? Number(config.safetyColumnHeightMeters) : defaultConflictConfig.safetyColumnHeightMeters
const step = config.safetyColumnSpatialStepMinutes != null ? Number(config.safetyColumnSpatialStepMinutes) : defaultConflictConfig.safetyColumnSpatialStepMinutes
const list = []
const twoR = 2 * R
const sampleTimes = []
for (let t = minMinutes; t <= maxMinutes; t += step) sampleTimes.push(t)
for (let i = 0; i < routeIds.length; i++) {
for (let j = i + 1; j < routeIds.length; j++) {
const ridA = routeIds[i]
const ridB = routeIds[j]
if (canPairFn && !canPairFn(ridA, ridB)) continue
for (const t of sampleTimes) {
const posA = getPositionAtMinutesFromK(ridA, t)
const posB = getPositionAtMinutesFromK(ridB, t)
if (!posA || !posB || posA.lng == null || posB.lng == null) continue
const horiz = horizontalDistanceMeters(posA.lng, posA.lat, posB.lng, posB.lat)
const altA = posA.alt != null ? Number(posA.alt) : 0
const altB = posB.alt != null ? Number(posB.alt) : 0
const dAlt = Math.abs(altA - altB)
if (horiz < twoR && dAlt < H) {
const needVertSep = Math.max(0, H - dAlt)
const nameA = routeNamesById[ridA] || `航线${ridA}`
const nameB = routeNamesById[ridB] || `航线${ridB}`
const vA = getSpeedKmhAtMinutes ? getSpeedKmhAtMinutes(ridA, t) : null
const vB = getSpeedKmhAtMinutes ? getSpeedKmhAtMinutes(ridB, t) : null
const vNumA = vA != null && Number.isFinite(vA) && vA > 0 ? vA : null
const vNumB = vB != null && Number.isFinite(vB) && vB > 0 ? vB : null
let vRef = 800
if (vNumA != null && vNumB != null) vRef = (vNumA + vNumB) / 2
else if (vNumA != null) vRef = vNumA
else if (vNumB != null) vRef = vNumB
const horizDeficitM = Math.max(0, twoR - horiz)
const reductionKmh = Math.min(150, Math.max(35, Math.round((horizDeficitM / 1000) * 10)))
const vSuggest = Math.max(120, Math.round(vRef - reductionKmh))
const kShiftMin = 1
const kLater = formatKLabel(t + kShiftMin)
const kEarlier = formatKLabel(t - kShiftMin)
const speedLine =
vNumA != null && vNumB != null
? `② 按当前推演航段速度(「${nameA}」约 ${Math.round(vNumA)} km/h、「${nameB}」约 ${Math.round(vNumB)} km/h),建议将其中一机在本段调至约 ${vSuggest} km/h(约比两机均值低 ${Math.round(vRef - vSuggest)} km/h),使通过该位置的时刻与另一机至少错开约 1 分钟。`
: `② 按参考航段速度约 ${Math.round(vRef)} km/h 计,建议将其中一机在本段调至约 ${vSuggest} km/h(约降低 ${Math.round(vRef - vSuggest)} km/h),使通过该冲突区域的先后间隔至少约 1 分钟。`
const kTimeLine =
`③ 将其中一条航线在冲突时刻附近航点的相对 K 时整体推迟或提前至少 ${kShiftMin} 分钟:例如由 ${formatKLabel(t)} 调至 ${kLater} 或更晚,或调至 ${kEarlier} 或更早。`
list.push({
type: CONFLICT_TYPE.SPACE,
subType: 'safety_column_spatial',
title: '飞机间距不足',
routeIds: [ridA, ridB],
routeNames: [nameA, nameB],
time: `${formatKLabel(t)}`,
position: `经度 ${((posA.lng + posB.lng) / 2).toFixed(5)}°, 纬度 ${((posA.lat + posB.lat) / 2).toFixed(5)}°, 高度约 ${((altA + altB) / 2).toFixed(0)} m`,
suggestion:
`两机在相同时刻占位过近:水平间距约 ${(horiz / 1000).toFixed(2)} km,高度差约 ${dAlt.toFixed(0)} m。` +
`① 将其中一条航线在冲突航段附近的高度上调或下调,使两机垂直间隔至少再拉开约 ${Math.ceil(needVertSep)} m(择一执行即可)。` +
speedLine +
kTimeLine,
severity: 'high',
positionLng: (posA.lng + posB.lng) / 2,
positionLat: (posA.lat + posB.lat) / 2,
positionAlt: (altA + altB) / 2,
minutesFromK: t,
safetyColumnHorizM: horiz,
safetyColumnDAltM: dAlt
})
break
}
}
}
}
return list
}
/**
* 隐藏圆柱体 时间冲突不同航线先后经过同一物理位置时间间隔 < ΔT
* 使用网格量化同一位置采样步长应小于 ΔT 以避免漏检
*/
export function detectSafetyColumnTemporal(
routeIds,
minMinutes,
maxMinutes,
getPositionAtMinutesFromK,
config = {},
routeNamesById = {}
) {
const deltaSec = config.safetyColumnTimeThresholdSeconds != null
? Number(config.safetyColumnTimeThresholdSeconds)
: defaultConflictConfig.safetyColumnTimeThresholdSeconds
const step = config.safetyColumnTemporalStepMinutes != null
? Number(config.safetyColumnTemporalStepMinutes)
: defaultConflictConfig.safetyColumnTemporalStepMinutes
const gLng = config.safetyColumnTemporalGridLngDeg != null
? Number(config.safetyColumnTemporalGridLngDeg)
: defaultConflictConfig.safetyColumnTemporalGridLngDeg
const gLat = config.safetyColumnTemporalGridLatDeg != null
? Number(config.safetyColumnTemporalGridLatDeg)
: defaultConflictConfig.safetyColumnTemporalGridLatDeg
const gAlt = config.safetyColumnTemporalGridAltMeters != null
? Number(config.safetyColumnTemporalGridAltMeters)
: defaultConflictConfig.safetyColumnTemporalGridAltMeters
const cellMap = new Map()
for (const rid of routeIds) {
for (let t = minMinutes; t <= maxMinutes; t += step) {
const pos = getPositionAtMinutesFromK(rid, t)
if (!pos || pos.lng == null || pos.lat == null) continue
const lng = Number(pos.lng)
const lat = Number(pos.lat)
const altN = Number(pos.alt) || 0
const ix = gLng > 0 ? Math.floor(lng / gLng) : 0
const iy = gLat > 0 ? Math.floor(lat / gLat) : 0
const iz = gAlt > 0 ? Math.floor(altN / gAlt) : 0
const key = `${ix}_${iy}_${iz}`
let arr = cellMap.get(key)
if (!arr) {
arr = []
cellMap.set(key, arr)
}
if (arr.length < 200) {
arr.push({ routeId: rid, tMinutes: t, lng: pos.lng, lat: pos.lat, alt: Number(pos.alt) || 0 })
}
}
}
const reported = new Set()
const list = []
for (const [, samples] of cellMap) {
if (samples.length < 2) continue
samples.sort((a, b) => a.tMinutes - b.tMinutes)
for (let a = 0; a < samples.length; a++) {
for (let b = a + 1; b < samples.length; b++) {
const sa = samples[a]
const sb = samples[b]
if (sa.routeId === sb.routeId) continue
const dtSec = Math.abs(sb.tMinutes - sa.tMinutes) * 60
if (dtSec <= 1e-6 || dtSec >= deltaSec) continue
const idA = String(sa.routeId)
const idB = String(sb.routeId)
const rLo = idA < idB ? idA : idB
const rHi = idA < idB ? idB : idA
const t1 = Math.min(sa.tMinutes, sb.tMinutes)
const t2 = Math.max(sa.tMinutes, sb.tMinutes)
const pairKey = `${rLo}_${rHi}_${t1.toFixed(5)}_${t2.toFixed(5)}`
if (reported.has(pairKey)) continue
reported.add(pairKey)
const nameA = routeNamesById[sa.routeId] || `航线${sa.routeId}`
const nameB = routeNamesById[sb.routeId] || `航线${sb.routeId}`
const tEarly = sa.tMinutes <= sb.tMinutes ? sa : sb
const tLate = sa.tMinutes <= sb.tMinutes ? sb : sa
const needDelaySec = Math.ceil(deltaSec - dtSec)
const midLng = (tEarly.lng + tLate.lng) / 2
const midLat = (tEarly.lat + tLate.lat) / 2
const midAlt = (tEarly.alt + tLate.alt) / 2
list.push({
type: CONFLICT_TYPE.TIME,
subType: 'safety_column_temporal',
title: '先后经过同点时间过近',
routeIds: [sa.routeId, sb.routeId],
routeNames: [nameA, nameB],
time: `先后约 ${Math.round(dtSec)} s(约 ${formatKLabel(tEarly.tMinutes)}${formatKLabel(tLate.tMinutes)}`,
position: `经度 ${midLng.toFixed(5)}°, 纬度 ${midLat.toFixed(5)}°, 高度约 ${midAlt.toFixed(0)} m`,
suggestion:
`两机先后经过同一空域位置,时间间隔仅约 ${Math.round(dtSec)} s,低于安全先后间隔要求。` +
`① 将其中一条航线相关航点的相对 K 时整体前移或后移,使先后到达间隔至少再拉开约 ${needDelaySec} s 以上。` +
`② 微调其中一机在邻近航段的飞行速度,使到达该位置的时刻错开。` +
`③ 若仍受航路结构限制,可为其中一机分配不同高度层,通过垂直间隔避免同点先后过近。`,
severity: 'high',
positionLng: midLng,
positionLat: midLat,
positionAlt: midAlt,
minutesFromK: tEarly.tMinutes,
safetyColumnTemporalDtSec: dtSec
})
}
}
}
return list
}
/**
* 空间冲突推演航迹最小间隔
* getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } } | null
* routeIds: 参与推演的航线 id 列表
* minMinutes, maxMinutes: 推演时间范围相对 K 分钟
*/
export function detectTrackSeparation(routeIds, minMinutes, maxMinutes, getPositionAtMinutesFromK, config = {}) {
const minSep = config.minTrackSeparationMeters != null ? config.minTrackSeparationMeters : defaultConflictConfig.minTrackSeparationMeters
const step = config.trackSampleStepMinutes != null ? config.trackSampleStepMinutes : defaultConflictConfig.trackSampleStepMinutes
const list = []
const sampleTimes = []
for (let t = minMinutes; t <= maxMinutes; t += step) sampleTimes.push(t)
for (let i = 0; i < routeIds.length; i++) {
for (let j = i + 1; j < routeIds.length; j++) {
const ridA = routeIds[i]
const ridB = routeIds[j]
for (const t of sampleTimes) {
const posA = getPositionAtMinutesFromK(ridA, t)
const posB = getPositionAtMinutesFromK(ridB, t)
if (!posA || !posB || posA.lng == null || posB.lng == null) continue
const d = distanceMeters(posA.lng, posA.lat, posA.alt, posB.lng, posB.lat, posB.alt)
if (d < minSep) {
list.push({
type: CONFLICT_TYPE.SPACE,
subType: 'track_separation',
title: '航迹间隔过小',
routeIds: [ridA, ridB],
time: `${formatKLabel(t)}`,
position: `经度 ${(posA.lng + posB.lng) / 2}°, 纬度 ${(posA.lat + posB.lat) / 2}°`,
suggestion: `两机在该时刻距离约 ${(d / 1000).toFixed(1)} km,小于最小间隔 ${minSep / 1000} km。建议调整航线或时间窗,或增大最小间隔配置。`,
severity: 'high',
positionLng: (posA.lng + posB.lng) / 2,
positionLat: (posA.lat + posB.lat) / 2,
positionAlt: (posA.alt + posB.alt) / 2,
minutesFromK: t,
distanceMeters: d
})
break // 同一对航线只报一次(取首次发生时刻)
}
}
}
}
return list
}
/**
* 空间冲突摆放平台图标距离过小
* platformIcons: [{ id, lng, lat, name? }]
*/
export function detectPlatformPlacementTooClose(platformIcons, config = {}) {
const minDist = config.minPlatformPlacementMeters != null ? config.minPlatformPlacementMeters : defaultConflictConfig.minPlatformPlacementMeters
const list = []
for (let i = 0; i < platformIcons.length; i++) {
for (let j = i + 1; j < platformIcons.length; j++) {
const a = platformIcons[i]
const b = platformIcons[j]
const lng1 = Number(a.lng)
const lat1 = Number(a.lat)
const lng2 = Number(b.lng)
const lat2 = Number(b.lat)
if (!Number.isFinite(lng1) || !Number.isFinite(lat1) || !Number.isFinite(lng2) || !Number.isFinite(lat2)) continue
const d = distanceMeters(lng1, lat1, 0, lng2, lat2, 0)
if (d < minDist) {
list.push({
type: CONFLICT_TYPE.SPACE,
subType: 'platform_placement',
title: '平台摆放距离过小',
routeIds: [],
routeNames: [a.name || `平台${a.id}`, b.name || `平台${b.id}`],
position: `经度 ${((lng1 + lng2) / 2).toFixed(5)}°, 纬度 ${((lat1 + lat2) / 2).toFixed(5)}°`,
suggestion: `两平台距离约 ${(d / 1000).toFixed(1)} km,小于最小摆放间隔 ${minDist / 1000} km。建议移动其中一方或增大最小间隔配置。`,
severity: 'medium',
positionLng: (lng1 + lng2) / 2,
positionLat: (lat1 + lat2) / 2,
positionAlt: 0,
distanceMeters: d
})
}
}
}
return list
}
/**
* 点是否在多边形内二维不考虑高度
* points: [{ lng, lat }] [{ x, y }]顺时针或逆时针
*/
export function pointInPolygon(lng, lat, points) {
if (!points || points.length < 3) return false
const x = Number(lng)
const y = Number(lat)
let inside = false
const n = points.length
for (let i = 0, j = n - 1; i < n; j = i++) {
const pi = points[i]
const pj = points[j]
const xi = pi.lng != null ? Number(pi.lng) : Number(pi.x)
const yi = pi.lat != null ? Number(pi.lat) : Number(pi.y)
const xj = pj.lng != null ? Number(pj.lng) : Number(pj.x)
const yj = pj.lat != null ? Number(pj.lat) : Number(pj.y)
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside
}
return inside
}
/**
* 空间冲突禁限区入侵考虑高度带
* 每条航线在推演时间内采样位置若落入禁限区多边形且高度在 [altMin, altMax] 内则报
* getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } }
* restrictedZones: [{ name, points: [{lng,lat}], altMin?, altMax? }]points 可从 frontend_drawings type polygon name 含禁限关键词的实体解析
*/
export function detectRestrictedZoneIntrusion(routeIds, minMinutes, maxMinutes, getPositionAtMinutesFromK, restrictedZones, config = {}) {
const step = config.trackSampleStepMinutes != null ? config.trackSampleStepMinutes : defaultConflictConfig.trackSampleStepMinutes
const useAlt = config.useRestrictedAltitudeBand !== false
const list = []
if (!restrictedZones || restrictedZones.length === 0) return list
for (const rid of routeIds) {
for (let t = minMinutes; t <= maxMinutes; t += step) {
const pos = getPositionAtMinutesFromK(rid, t)
if (!pos || pos.lng == null) continue
const alt = pos.alt != null ? Number(pos.alt) : 0
for (const zone of restrictedZones) {
const points = zone.points || zone.positions
if (!points || points.length < 3) continue
const inPoly = pointInPolygon(pos.lng, pos.lat, points)
if (!inPoly) continue
const altMin = zone.altMin != null ? Number(zone.altMin) : -Infinity
const altMax = zone.altMax != null ? Number(zone.altMax) : Infinity
const inAlt = !useAlt || (alt >= altMin && alt <= altMax)
if (inAlt) {
list.push({
type: CONFLICT_TYPE.SPACE,
subType: 'restricted_zone',
title: '禁限区入侵',
routeIds: [rid],
zoneName: zone.name || '禁限区',
time: `${formatKLabel(t)}`,
position: `经度 ${pos.lng.toFixed(5)}°, 纬度 ${pos.lat.toFixed(5)}°, 高度 ${alt} m`,
suggestion: `航迹在 ${zone.name || '禁限区'} 内且高度在 [${altMin}, ${altMax}] m 范围内。建议调整航线避开该区域或调整禁限区高度带。`,
severity: 'high',
positionLng: pos.lng,
positionLat: pos.lat,
positionAlt: alt,
minutesFromK: t
})
break // 该航线该时刻只报一个区
}
}
}
}
return list
}
/**
* frontend_drawings entities 中解析禁限区polygon/rectangle/circle/sector
* entity: { type, label/name, points/positions, data: { altMin, altMax } }
*/
export function parseRestrictedZonesFromDrawings(entities, keywords = defaultConflictConfig.restrictedZoneNameKeywords) {
const zones = []
if (!entities || !Array.isArray(entities)) return zones
const nameMatches = (name) => {
const n = (name || '').toString()
return keywords.some(kw => n.includes(kw))
}
for (const e of entities) {
const name = (e.label || e.name || (e.data && e.data.name) || '').toString()
if (!nameMatches(name)) continue
let points = e.points || (e.data && e.data.points) || (e.positions && e.positions.map(p => {
if (p.lng != null) return { lng: p.lng, lat: p.lat }
if (p.x != null) return { lng: p.x, lat: p.y }
return null
}).filter(Boolean))
if (!points && e.data) {
if (e.type === 'circle' && e.data.center) {
const c = e.data.center
const r = (e.data.radius || 0) / 111320 // 约米转度
const lng = c.lng != null ? c.lng : c.x
const lat = c.lat != null ? c.lat : c.y
const n = 32
points = []
for (let i = 0; i <= n; i++) {
const angle = (i / n) * 2 * Math.PI
points.push({ lng: lng + r * Math.cos(angle), lat: lat + r * Math.sin(angle) })
}
} else if (e.type === 'rectangle' && (e.data.bounds || e.data.coordinates || (e.data.points && e.data.points.length >= 2))) {
const b = e.data.bounds || e.data.coordinates || {}
const west = b.west ?? b.minLng
const south = b.south ?? b.minLat
const east = b.east ?? b.maxLng
const north = b.north ?? b.maxLat
if (e.data.points && e.data.points.length >= 2) {
points = e.data.points
} else if (west != null && south != null && east != null && north != null) {
points = [
{ lng: west, lat: south },
{ lng: east, lat: south },
{ lng: east, lat: north },
{ lng: west, lat: north }
]
}
}
}
if (!points || points.length < 3) continue
const altMin = e.data && e.data.altMin != null ? e.data.altMin : undefined
const altMax = e.data && e.data.altMax != null ? e.data.altMax : undefined
zones.push({ name, points, altMin, altMax })
}
return zones
}
/**
* 合并并规范化冲突列表统一 idseveritycategory 等供 UI 展示
*/
export function normalizeConflictList(conflictsByType, startId = 1) {
const list = []
let id = startId
for (const c of conflictsByType) {
list.push({
id: id++,
type: c.type || CONFLICT_TYPE.TIME,
subType: c.subType || '',
title: c.title || '冲突',
routeName: c.routeNames && c.routeNames.length ? c.routeNames.join('、') : c.routeName,
routeIds: c.routeIds || [],
fromWaypoint: c.fromWaypoint,
toWaypoint: c.toWaypoint,
time: c.time,
position: c.position,
suggestion: c.suggestion,
severity: c.severity || 'high',
positionLng: c.positionLng,
positionLat: c.positionLat,
positionAlt: c.positionAlt,
minutesFromK: c.minutesFromK,
holdCenter: c.holdCenter,
...c
})
}
return list
}

14
ruoyi-ui/src/utils/imageUrl.js

@ -0,0 +1,14 @@
/**
* 解析背景图/图片 URL
* - data:image/... (base64) http(s):// 开头:原样返回
* - 否则视为若依 profile 路径拼接 base API
*/
export function resolveImageUrl(img) {
if (!img) return ''
if (img.startsWith('data:') || img.startsWith('http://') || img.startsWith('https://')) {
return img
}
const base = process.env.VUE_APP_BASE_API || ''
const path = img.startsWith('/') ? img : '/' + img
return base + path
}

27
ruoyi-ui/src/utils/request.js

@ -31,8 +31,8 @@ service.interceptors.request.use(config => {
if (getToken() && !isToken) {
config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
}
// get请求映射params参数
if (config.method === 'get' && config.params) {
// get、delete 请求映射 params 参数到 URL(确保 query 参数正确发送,部分环境 DELETE 的 params 可能丢失)
if ((config.method === 'get' || config.method === 'delete') && config.params && Object.keys(config.params).length > 0) {
let url = config.url + '?' + tansParams(config.params)
url = url.slice(0, -1)
config.params = {}
@ -66,6 +66,10 @@ service.interceptors.request.use(config => {
}
}
}
// FormData 上传时移除 Content-Type,让浏览器自动设置 multipart/form-data; boundary=...
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
}
return config
}, error => {
console.log(error)
@ -85,7 +89,7 @@ service.interceptors.response.use(res => {
if (code === 401) {
if (!isRelogin.show) {
isRelogin.show = true
MessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {
MessageBox.confirm('登录状态已过期或您的账号已在其他设备登录,请重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {
isRelogin.show = false
store.dispatch('LogOut').then(() => {
location.href = '/index'
@ -109,6 +113,23 @@ service.interceptors.response.use(res => {
}
},
error => {
// HTTP 401:token 失效或被顶掉,触发重新登录(与 success 中 res.data.code===401 一致)
const status = error.response && error.response.status
const code = error.response && error.response.data && error.response.data.code
if (status === 401 || code === 401) {
if (!isRelogin.show) {
isRelogin.show = true
MessageBox.confirm('登录状态已过期或您的账号已在其他设备登录,请重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {
isRelogin.show = false
store.dispatch('LogOut').then(() => {
location.href = '/index'
})
}).catch(() => {
isRelogin.show = false
})
}
return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
}
console.log('err' + error)
let { message } = error
if (message == "Network Error") {

130
ruoyi-ui/src/utils/timelinePosition.js

@ -0,0 +1,130 @@
/**
* 与推演时间轴相关的取点工具函数
* 该文件用于在主线程/Worker 共享一套插值逻辑避免在大数据量冲突检测时重复实现
*/
/** 两点间近似距离(米),含高度差 */
export function segmentDistanceMeters(a, b) {
const R = 6371000
const lat1 = (Number(a.lat) * Math.PI) / 180
const lat2 = (Number(b.lat) * Math.PI) / 180
const dlat = ((Number(b.lat) - Number(a.lat)) * Math.PI) / 180
const dlng = ((Number(b.lng) - Number(a.lng)) * Math.PI) / 180
const sinDLat = Math.sin(dlat / 2)
const sinDLng = Math.sin(dlng / 2)
const aa = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng
const c = 2 * Math.atan2(Math.sqrt(aa), Math.sqrt(1 - aa))
const horizontal = R * c
const dalt = (Number(b.alt) || 0) - (Number(a.alt) || 0)
return Math.sqrt(horizontal * horizontal + dalt * dalt)
}
/** 从路径片段中按比例 t(0~1)取点:按弧长比例插值 */
export function getPositionAlongPathSlice(pathSlice, t) {
if (!pathSlice || pathSlice.length === 0) return null
if (pathSlice.length === 1 || t <= 0) return pathSlice[0]
if (t >= 1) return pathSlice[pathSlice.length - 1]
let totalLen = 0
const lengths = [0]
for (let i = 1; i < pathSlice.length; i++) {
totalLen += segmentDistanceMeters(pathSlice[i - 1], pathSlice[i])
lengths.push(totalLen)
}
const targetDist = t * totalLen
let idx = 0
while (idx < lengths.length - 1 && lengths[idx + 1] < targetDist) idx++
const p0 = pathSlice[idx]
const p1 = pathSlice[idx + 1]
const segLen = lengths[idx + 1] - lengths[idx]
const segT = segLen > 0 ? (targetDist - lengths[idx]) / segLen : 0
return {
lng: Number(p0.lng) + (Number(p1.lng) - Number(p0.lng)) * segT,
lat: Number(p0.lat) + (Number(p1.lat) - Number(p0.lat)) * segT,
alt: (Number(p0.alt) || 0) + ((Number(p1.alt) || 0) - (Number(p0.alt) || 0)) * segT
}
}
/**
* 从时间轴中取当前推演时间对应的位置支持 fly/wait/hold
* segments: buildRouteTimeline 产物在主线程构建后传入 worker 使用
*/
export function 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]
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]]
}
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 denom = (s.endTime - s.startTime)
const t = denom > 0 ? Math.max(0, Math.min(1, (minutesFromK - s.startTime) / denom)) : 0
if (s.type === 'wait') {
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) {
const endIdx = segmentEndIndices[s.legIndex]
if (path[endIdx]) return path[endIdx]
}
return s.startPos
}
if (s.type === 'hold' && s.holdPath && s.holdPath.length) {
if (s.holdClosedLoopPath && s.holdClosedLoopPath.length >= 2 && s.holdLoopLength > 0 && s.speedKmh != null) {
const distM = (minutesFromK - s.startTime) * (s.speedKmh * 1000 / 60)
const n = Math.max(0, s.holdN != null ? s.holdN : 0)
const totalFly = (s.holdExitDistanceOnLoop || 0) + n * s.holdLoopLength
if (totalFly <= 0) return s.startPos
if (distM >= totalFly) return s.endPos
if (distM < n * s.holdLoopLength) {
const distOnLoop = ((distM % s.holdLoopLength) + s.holdLoopLength) % s.holdLoopLength
const tPath = distOnLoop / s.holdLoopLength
return getPositionAlongPathSlice(s.holdClosedLoopPath, tPath)
}
const distToExit = distM - n * s.holdLoopLength
const exitDist = s.holdExitDistanceOnLoop || 1
const tPath = Math.min(1, distToExit / exitDist)
if (s.holdEntryToExitPath && s.holdEntryToExitPath.length > 0) {
return getPositionAlongPathSlice(s.holdEntryToExitPath, tPath)
}
return getPositionAlongPathSlice(s.holdClosedLoopPath, distToExit / s.holdLoopLength)
}
if (s.holdGeometricDist != null && s.holdGeometricDist > 0 && s.speedKmh != null) {
const speedMpMin = (s.speedKmh * 1000) / 60
const distM = (minutesFromK - s.startTime) * speedMpMin
if (distM >= s.holdGeometricDist) return s.endPos
const tPath = Math.max(0, Math.min(1, distM / s.holdGeometricDist))
return getPositionAlongPathSlice(s.holdPath, tPath)
}
return getPositionAlongPathSlice(s.holdPath, t)
}
if (s.type === 'fly' && s.pathSlice && s.pathSlice.length) {
return getPositionAlongPathSlice(s.pathSlice, t)
}
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) {
const startIdx = s.legIndex === 0 ? 0 : segmentEndIndices[s.legIndex - 1]
const endIdx = segmentEndIndices[s.legIndex]
const pathSlice = path.slice(startIdx, endIdx + 1)
if (pathSlice.length > 0) return getPositionAlongPathSlice(pathSlice, t)
}
return {
lng: Number(s.startPos.lng) + (Number(s.endPos.lng) - Number(s.startPos.lng)) * t,
lat: Number(s.startPos.lat) + (Number(s.endPos.lat) - Number(s.startPos.lat)) * t,
alt: (Number(s.startPos.alt) || 0) + ((Number(s.endPos.alt) || 0) - (Number(s.startPos.alt) || 0)) * t
}
}
}
return last.endPos
}

334
ruoyi-ui/src/utils/websocket.js

@ -0,0 +1,334 @@
/**
* WebSocket 房间连接服务SockJS + STOMP
*/
import SockJS from 'sockjs-client'
import { Client } from '@stomp/stompjs'
import { getToken } from '@/utils/auth'
const WS_BASE = process.env.VUE_APP_BASE_API || '/dev-api'
/**
* 创建房间 WebSocket 连接
* @param {Object} options
* @param {string|number} options.roomId - 房间 ID
* @param {Function} options.onMembers - 收到成员列表回调 (members) => {}
* @param {Function} options.onMemberJoined - 成员加入回调 (member) => {}
* @param {Function} options.onMemberLeft - 成员离开回调 (member, sessionId) => {}
* @param {Function} options.onChatMessage - 群聊消息回调 (msg) => {}
* @param {Function} options.onPrivateChat - 私聊消息回调 (msg) => {}
* @param {Function} options.onChatHistory - 群聊历史回调 (messages) => {}
* @param {Function} options.onPrivateChatHistory - 私聊历史回调 (targetUserId, messages) => {}
* @param {Function} options.onSyncRouteVisibility - 航线显隐同步回调 (routeId, visible, senderUserId) => {}
* @param {Function} options.onSyncWaypoints - 航点变更同步回调 (routeId, senderUserId) => {}
* @param {Function} options.onSyncPlatformIcons - 平台图标变更同步回调 (senderUserId) => {}
* @param {Function} options.onSyncRoomDrawings - 空域图形变更同步回调 (senderUserId) => {}
* @param {Function} options.onSyncPlatformStyles - 探测区/威力区样式变更同步回调 (senderUserId) => {}
* @param {Function} options.onRoomState - 新加入时收到的房间状态 (visibleRouteIds: number[]) => {}
* @param {Function} options.onObjectEditLock - 对象被某成员编辑锁定 (msg: { objectType, objectId, editor }) => {}
* @param {Function} options.onObjectEditUnlock - 对象编辑解锁 (msg: { objectType, objectId, sessionId }) => {}
* @param {Function} options.onConnected - 连接成功回调
* @param {Function} options.onDisconnected - 断开回调
* @param {Function} options.onError - 错误回调
* @param {string} [options.deviceId] - 设备标识
*/
export function createRoomWebSocket(options) {
const {
roomId,
onMembers,
onMemberJoined,
onMemberLeft,
onChatMessage,
onPrivateChat,
onChatHistory,
onPrivateChatHistory,
onSyncRouteVisibility,
onSyncWaypoints,
onSyncPlatformIcons,
onSyncRoomDrawings,
onSyncPlatformStyles,
onRoomState,
onObjectEditLock,
onObjectEditUnlock,
onConnected,
onDisconnected,
onError,
deviceId = 'web-' + Math.random().toString(36).slice(2, 10)
} = options
let client = null
let roomSubscription = null
let privateSubscription = null
let heartbeatTimer = null
let reconnectAttempts = 0
const maxReconnectAttempts = 10
const reconnectDelay = 2000
function getWsUrl() {
const token = getToken()
const base = window.location.origin + WS_BASE
const sep = base.includes('?') ? '&' : '?'
return base + '/ws' + (token ? sep + 'token=' + encodeURIComponent(token) : '')
}
function sendJoin() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'JOIN', deviceId })
})
}
}
function sendLeave() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'LEAVE' })
})
}
}
function sendPing() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'PING' })
})
}
}
function sendChat(content) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'CHAT', content })
})
}
}
function sendPrivateChat(targetUserId, targetUserName, content) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'PRIVATE_CHAT', targetUserId, targetUserName, content })
})
}
}
function sendPrivateChatHistoryRequest(targetUserId) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'PRIVATE_CHAT_HISTORY_REQUEST', targetUserId })
})
}
}
function sendSyncRouteVisibility(routeId, visible) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'SYNC_ROUTE_VISIBILITY', routeId, visible })
})
}
}
function sendSyncWaypoints(routeId) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'SYNC_WAYPOINTS', routeId })
})
}
}
function sendSyncPlatformIcons() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'SYNC_PLATFORM_ICONS' })
})
}
}
function sendSyncRoomDrawings() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'SYNC_ROOM_DRAWINGS' })
})
}
}
function sendSyncPlatformStyles() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'SYNC_PLATFORM_STYLES' })
})
}
}
/** 发送:当前成员锁定某对象进入编辑 */
function sendObjectEditLock(objectType, objectId) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'OBJECT_EDIT_LOCK', objectType, objectId })
})
}
}
/** 发送:当前成员解锁某对象(结束编辑) */
function sendObjectEditUnlock(objectType, objectId) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'OBJECT_EDIT_UNLOCK', objectType, objectId })
})
}
}
function startHeartbeat() {
stopHeartbeat()
heartbeatTimer = setInterval(sendPing, 30000)
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
}
function handleRoomMessage(message) {
try {
const body = JSON.parse(message.body)
const type = body.type
if (type === 'MEMBER_LIST' && body.members) {
onMembers && onMembers(body.members)
} else if (type === 'MEMBER_JOINED' && body.member) {
onMemberJoined && onMemberJoined(body.member)
} else if (type === 'MEMBER_LEFT' && body.member) {
onMemberLeft && onMemberLeft(body.member, body.sessionId)
} else if (type === 'CHAT' && body.sender) {
onChatMessage && onChatMessage(body)
} else if (type === 'SYNC_ROUTE_VISIBILITY' && body.routeId != null) {
onSyncRouteVisibility && onSyncRouteVisibility(body.routeId, !!body.visible, body.senderSessionId)
} else if (type === 'SYNC_WAYPOINTS' && body.routeId != null) {
onSyncWaypoints && onSyncWaypoints(body.routeId, body.senderSessionId)
} else if (type === 'SYNC_PLATFORM_ICONS') {
onSyncPlatformIcons && onSyncPlatformIcons(body.senderSessionId)
} else if (type === 'SYNC_ROOM_DRAWINGS') {
onSyncRoomDrawings && onSyncRoomDrawings(body.senderSessionId)
} else if (type === 'SYNC_PLATFORM_STYLES') {
onSyncPlatformStyles && onSyncPlatformStyles(body.senderSessionId)
} else if (type === 'OBJECT_EDIT_LOCK' && body.objectType != null && body.objectId != null && body.editor) {
onObjectEditLock && onObjectEditLock(body)
} else if (type === 'OBJECT_EDIT_UNLOCK' && body.objectType != null && body.objectId != null) {
onObjectEditUnlock && onObjectEditUnlock(body)
}
} catch (e) {
console.warn('[WebSocket] parse message error:', e)
}
}
function handlePrivateMessage(message) {
try {
const body = JSON.parse(message.body)
const type = body.type
if (type === 'PRIVATE_CHAT' && body.sender) {
onPrivateChat && onPrivateChat(body)
} else if (type === 'CHAT_HISTORY' && Array.isArray(body.messages)) {
onChatHistory && onChatHistory(body.messages)
} else if (type === 'PRIVATE_CHAT_HISTORY' && body.targetUserId != null && Array.isArray(body.messages)) {
onPrivateChatHistory && onPrivateChatHistory(body.targetUserId, body.messages)
} else if (type === 'ROOM_STATE' && Array.isArray(body.visibleRouteIds)) {
onRoomState && onRoomState(body.visibleRouteIds)
}
} catch (e) {
console.warn('[WebSocket] parse private message error:', e)
}
}
function connect() {
const token = getToken()
if (!token) {
onError && onError(new Error('未登录'))
return
}
const sock = new SockJS(getWsUrl())
client = new Client({
webSocketFactory: () => sock,
reconnectDelay: 0,
heartbeatIncoming: 0,
heartbeatOutgoing: 0,
onConnect: () => {
reconnectAttempts = 0
roomSubscription = client.subscribe('/topic/room/' + roomId, handleRoomMessage)
privateSubscription = client.subscribe('/user/queue/private', handlePrivateMessage)
sendJoin()
startHeartbeat()
onConnected && onConnected()
},
onStompError: (frame) => {
console.warn('[WebSocket] STOMP error:', frame)
onError && onError(new Error(frame.headers?.message || '连接错误'))
},
onWebSocketClose: () => {
stopHeartbeat()
roomSubscription = null
privateSubscription = null
onDisconnected && onDisconnected()
}
})
client.activate()
}
function disconnect() {
stopHeartbeat()
sendLeave()
if (roomSubscription) {
roomSubscription.unsubscribe()
roomSubscription = null
}
if (privateSubscription) {
privateSubscription.unsubscribe()
privateSubscription = null
}
if (client) {
client.deactivate()
client = null
}
}
function tryReconnect() {
if (reconnectAttempts >= maxReconnectAttempts) return
reconnectAttempts++
setTimeout(() => {
disconnect()
connect()
}, reconnectDelay)
}
connect()
return {
disconnect,
reconnect: connect,
sendChat,
sendPrivateChat,
sendPrivateChatHistoryRequest,
sendSyncRouteVisibility,
sendSyncWaypoints,
sendSyncPlatformIcons,
sendSyncRoomDrawings,
sendSyncPlatformStyles,
sendObjectEditLock,
sendObjectEditUnlock,
get connected() {
return client && client.connected
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save