Browse Source

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

# Conflicts:
#	ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomPlatformIconController.java
#	ruoyi-ui/src/lang/en.js
#	ruoyi-ui/src/lang/zh.js
ctw
menghao 3 weeks ago
parent
commit
cb30e19610
  1. 64
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomPlatformIconController.java
  2. 76
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java
  3. 130
      ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
  4. 6
      ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java
  5. 219
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomUserActivityService.java
  6. 4
      ruoyi-system/src/main/java/com/ruoyi/system/domain/ObjectOperationLog.java
  7. 3
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/RoomPlatformIconMapper.java
  8. 96
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ObjectOperationLogServiceImpl.java
  9. 7
      ruoyi-system/src/main/resources/mapper/system/RoomPlatformIconMapper.xml
  10. 11
      ruoyi-ui/src/lang/en.js
  11. 11
      ruoyi-ui/src/lang/zh.js
  12. 26
      ruoyi-ui/src/layout/components/TagsView/index.vue
  13. 97
      ruoyi-ui/src/utils/websocket.js
  14. 639
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  15. 244
      ruoyi-ui/src/views/cesiumMap/MenuGlyph.vue
  16. 10
      ruoyi-ui/src/views/cesiumMap/index.vue
  17. 25
      ruoyi-ui/src/views/childRoom/LeftMenu.vue
  18. 33
      ruoyi-ui/src/views/childRoom/SixStepsOverlay.vue
  19. 15
      ruoyi-ui/src/views/childRoom/StepCanvasContent.vue
  20. 16
      ruoyi-ui/src/views/childRoom/TaskPageContent.vue
  21. 17
      ruoyi-ui/src/views/childRoom/UnderstandingStepContent.vue
  22. 154
      ruoyi-ui/src/views/childRoom/index.vue
  23. 18
      ruoyi-ui/src/views/dialogs/IconSelectDialog.vue
  24. 374
      ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue
  25. 35
      ruoyi-ui/src/views/selectRoom/index.vue

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

@ -5,12 +5,15 @@ 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.alibaba.fastjson2.JSON;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.system.domain.ObjectOperationLog;
import com.ruoyi.system.domain.RoomPlatformIcon;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IRoomPlatformIconService;
/**
@ -26,6 +29,9 @@ public class RoomPlatformIconController extends BaseController {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private IObjectOperationLogService objectOperationLogService;
/**
* 查询房间地图平台图标列表分页
*/
@ -74,6 +80,23 @@ public class RoomPlatformIconController extends BaseController {
@PostMapping
public AjaxResult add(@RequestBody RoomPlatformIcon roomPlatformIcon) {
int rows = roomPlatformIconService.insert(roomPlatformIcon);
if (rows > 0 && roomPlatformIcon.getId() != null) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomPlatformIcon.getRoomId());
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_INSERT);
opLog.setObjectType(ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON);
opLog.setObjectId(String.valueOf(roomPlatformIcon.getId()));
opLog.setObjectName(roomPlatformIcon.getPlatformName());
opLog.setDetail("在地图摆放平台:" + (roomPlatformIcon.getPlatformName() != null ? roomPlatformIcon.getPlatformName() : "") + "(实例ID=" + roomPlatformIcon.getId() + ")");
opLog.setSnapshotAfter(JSON.toJSONString(roomPlatformIcon));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录房间地图平台摆放操作日志失败", e);
}
}
return rows > 0 ? success(roomPlatformIcon) : error("新增失败");
}
@ -84,7 +107,29 @@ public class RoomPlatformIconController extends BaseController {
@Log(title = "房间地图平台图标", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody RoomPlatformIcon roomPlatformIcon) {
return toAjax(roomPlatformIconService.update(roomPlatformIcon));
RoomPlatformIcon before = roomPlatformIcon.getId() != null
? roomPlatformIconService.selectById(roomPlatformIcon.getId()) : null;
int rows = roomPlatformIconService.update(roomPlatformIcon);
if (rows > 0 && before != null) {
RoomPlatformIcon after = roomPlatformIconService.selectById(roomPlatformIcon.getId());
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(before.getRoomId());
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_UPDATE);
opLog.setObjectType(ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON);
opLog.setObjectId(String.valueOf(before.getId()));
opLog.setObjectName(after != null ? after.getPlatformName() : before.getPlatformName());
opLog.setDetail("调整地图平台位置/朝向/缩放:" + (before.getPlatformName() != null ? before.getPlatformName() : "") + "(实例ID=" + before.getId() + ")");
opLog.setSnapshotBefore(JSON.toJSONString(before));
opLog.setSnapshotAfter(after != null ? JSON.toJSONString(after) : JSON.toJSONString(roomPlatformIcon));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录房间地图平台修改操作日志失败", e);
}
}
return toAjax(rows);
}
/**
@ -100,6 +145,23 @@ public class RoomPlatformIconController extends BaseController {
for (Long id : ids) {
if (id == null) continue;
RoomPlatformIcon icon = roomPlatformIconService.selectById(id);
if (icon != null) {
try {
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(icon.getRoomId());
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_DELETE);
opLog.setObjectType(ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON);
opLog.setObjectId(String.valueOf(id));
opLog.setObjectName(icon.getPlatformName());
opLog.setDetail("删除地图上的平台:" + (icon.getPlatformName() != null ? icon.getPlatformName() : "") + "(实例ID=" + id + ")");
opLog.setSnapshotBefore(JSON.toJSONString(icon));
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录房间地图平台删除操作日志失败", e);
}
}
if (icon != null && icon.getRoomId() != null) {
String key = "room:" + icon.getRoomId() + ":platformIcons:platforms";
redisTemplate.opsForHash().delete(key, String.valueOf(id));

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

@ -18,8 +18,10 @@ 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.RoomPlatformIcon;
import com.ruoyi.system.domain.Routes;
import com.ruoyi.system.service.IObjectOperationLogService;
import com.ruoyi.system.service.IRoomPlatformIconService;
import com.ruoyi.system.service.IRoutesService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
@ -47,6 +49,9 @@ public class RoutesController extends BaseController
private IRoutesService routesService;
@Autowired
private IRoomPlatformIconService roomPlatformIconService;
@Autowired
private IObjectOperationLogService objectOperationLogService;
@Autowired
@ -85,7 +90,14 @@ public class RoutesController extends BaseController
String hashField = isPlatformIcon && dto.getPlatformIconInstanceId() != null
? String.valueOf(dto.getPlatformIconInstanceId())
: String.valueOf(dto.getPlatformId());
redisTemplate.opsForHash().put(key, hashField, JSON.toJSONString(dto));
Object oldRedisVal = redisTemplate.opsForHash().get(key, hashField);
if (oldRedisVal == null && isPlatformIcon) {
String legacyKey = "room:" + dto.getRoomId() + ":route:0:platforms";
oldRedisVal = redisTemplate.opsForHash().get(legacyKey, hashField);
}
String snapshotBefore = oldRedisVal != null ? oldRedisVal.toString() : null;
String snapshotAfter = JSON.toJSONString(dto);
redisTemplate.opsForHash().put(key, hashField, snapshotAfter);
if (isPlatformIcon && dto.getPlatformIconInstanceId() != null) {
String oldKey = "room:" + dto.getRoomId() + ":route:0:platforms";
@ -94,6 +106,41 @@ public class RoutesController extends BaseController
String oldKey = "room:" + dto.getRoomId() + ":route:0:platforms";
redisTemplate.opsForHash().delete(oldKey, String.valueOf(dto.getPlatformId()));
}
if (isPlatformIcon && dto.getPlatformIconInstanceId() != null) {
boolean styleUnchanged = snapshotBefore != null && snapshotBefore.equals(snapshotAfter);
try {
Long roomIdLong = null;
if (dto.getRoomId() != null && !dto.getRoomId().isEmpty()) {
roomIdLong = Long.valueOf(dto.getRoomId());
}
if (roomIdLong != null && !styleUnchanged) {
String opName = dto.getPlatformName();
if (opName == null || opName.isEmpty()) {
RoomPlatformIcon icon = roomPlatformIconService.selectById(dto.getPlatformIconInstanceId());
if (icon != null) {
opName = icon.getPlatformName();
}
}
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomIdLong);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_UPDATE);
opLog.setObjectType(ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON_STYLE);
opLog.setObjectId(String.valueOf(dto.getPlatformIconInstanceId()));
opLog.setObjectName(opName);
String colorHint = dto.getPlatformColor() != null && !dto.getPlatformColor().isEmpty()
? ",图标颜色=" + dto.getPlatformColor() : "";
opLog.setDetail("修改地图平台样式(实例ID=" + dto.getPlatformIconInstanceId() + colorHint + ")");
opLog.setSnapshotBefore(snapshotBefore);
opLog.setSnapshotAfter(snapshotAfter);
objectOperationLogService.saveLog(opLog);
}
} catch (Exception e) {
logger.warn("记录地图平台样式操作日志失败", e);
}
}
return success();
}
@ -145,10 +192,33 @@ public class RoutesController extends BaseController
if (roomId == null || platformIconInstanceId == null) {
return AjaxResult.error("roomId 与 platformIconInstanceId 不能为空");
}
String field = String.valueOf(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));
Object oldVal = redisTemplate.opsForHash().get(key, field);
if (oldVal == null) {
oldVal = redisTemplate.opsForHash().get(oldKey, field);
}
redisTemplate.opsForHash().delete(key, field);
redisTemplate.opsForHash().delete(oldKey, field);
if (oldVal != null) {
try {
RoomPlatformIcon icon = roomPlatformIconService.selectById(platformIconInstanceId);
ObjectOperationLog opLog = new ObjectOperationLog();
opLog.setRoomId(roomId);
opLog.setOperatorId(getUserId());
opLog.setOperatorName(getUsername());
opLog.setOperationType(ObjectOperationLog.TYPE_DELETE);
opLog.setObjectType(ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON_STYLE);
opLog.setObjectId(field);
opLog.setObjectName(icon != null ? icon.getPlatformName() : null);
opLog.setDetail("清除地图平台样式(实例ID=" + platformIconInstanceId + ")");
opLog.setSnapshotBefore(oldVal.toString());
objectOperationLogService.saveLog(opLog);
} catch (Exception e) {
logger.warn("记录清除地图平台样式操作日志失败", e);
}
}
return success();
}

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

@ -1,5 +1,6 @@
package com.ruoyi.websocket.controller;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -22,6 +23,7 @@ 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.RoomUserActivityService;
import com.ruoyi.websocket.service.RoomWebSocketService;
/**
@ -50,6 +52,9 @@ public class RoomWebSocketController {
@Autowired
private IRoomUserProfileService roomUserProfileService;
@Autowired
private RoomUserActivityService roomUserActivityService;
private static final String TYPE_JOIN = "JOIN";
private static final String TYPE_LEAVE = "LEAVE";
private static final String TYPE_PING = "PING";
@ -71,6 +76,8 @@ public class RoomWebSocketController {
private static final String TYPE_OBJECT_EDIT_LOCK = "OBJECT_EDIT_LOCK";
/** 对象编辑解锁 */
private static final String TYPE_OBJECT_EDIT_UNLOCK = "OBJECT_EDIT_UNLOCK";
/** 地图选中状态同步(供「当前操作」展示) */
private static final String TYPE_USER_SELECTION = "USER_SELECTION";
/**
* 处理房间消息JOINLEAVEPINGCHATPRIVATE_CHATSYNC_*
@ -118,6 +125,8 @@ public class RoomWebSocketController {
handleObjectEditLock(roomId, sessionId, loginUser, body);
} else if (TYPE_OBJECT_EDIT_UNLOCK.equals(type)) {
handleObjectEditUnlock(roomId, sessionId, loginUser, body);
} else if (TYPE_USER_SELECTION.equals(type)) {
handleUserSelection(roomId, sessionId, loginUser, body);
}
}
@ -134,12 +143,22 @@ public class RoomWebSocketController {
editor.put("nickName", profile != null ? profile.getDisplayName() : loginUser.getUser().getNickName());
editor.put("sessionId", sessionId);
String objectName = "";
if (body != null && body.get("objectName") != null) {
objectName = String.valueOf(body.get("objectName"));
}
roomUserActivityService.onEditLock(roomId, sessionId, editor, objectType, objectIdObj, objectName);
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);
if (!objectName.isEmpty()) {
msg.put("objectName", objectName);
}
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
roomUserActivityService.broadcastSnapshot(roomId);
}
/** 广播:某成员解锁某对象(结束编辑) */
@ -148,12 +167,51 @@ public class RoomWebSocketController {
Object objectIdObj = body != null ? body.get("objectId") : null;
if (objectType == null || objectIdObj == null) return;
roomUserActivityService.onEditUnlock(roomId, sessionId, objectType, objectIdObj);
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);
roomUserActivityService.broadcastSnapshot(roomId);
}
private void handleUserSelection(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) {
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);
String summary = body != null && body.get("summary") != null ? String.valueOf(body.get("summary")) : "";
int count = 0;
if (body != null && body.get("selectedCount") instanceof Number) {
count = ((Number) body.get("selectedCount")).intValue();
} else if (body != null && body.get("selectedCount") != null) {
try {
count = Integer.parseInt(String.valueOf(body.get("selectedCount")));
} catch (NumberFormatException ignored) {
count = 0;
}
}
List<Map<String, Object>> items = new ArrayList<>();
if (body != null && body.get("items") instanceof List) {
for (Object o : (List<?>) body.get("items")) {
if (o instanceof Map) {
Map<?, ?> raw = (Map<?, ?>) o;
Map<String, Object> one = new HashMap<>();
for (Map.Entry<?, ?> e : raw.entrySet()) {
one.put(String.valueOf(e.getKey()), e.getValue());
}
items.add(one);
}
}
}
roomUserActivityService.onSelection(roomId, sessionId, editor, summary, count, items);
roomUserActivityService.broadcastSnapshot(roomId);
}
@SuppressWarnings("unchecked")
@ -179,12 +237,15 @@ public class RoomWebSocketController {
chatHistoryMsg.put("messages", chatHistory);
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", chatHistoryMsg);
roomUserActivityService.sendSnapshotToUser(roomId, loginUser.getUsername());
// 小房间内每人展示各自内容,新加入用户不同步他人的可见航线
}
private void handleLeave(Long roomId, String sessionId, LoginUser loginUser) {
RoomMemberDTO member = buildMember(loginUser, sessionId, roomId, null);
roomWebSocketService.leaveRoom(roomId, sessionId, loginUser.getUserId());
roomUserActivityService.removeSession(roomId, sessionId);
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_MEMBER_LEFT);
@ -193,6 +254,7 @@ public class RoomWebSocketController {
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
roomOnlineMemberBroadcastService.broadcastAfterMembershipChange(roomId);
roomUserActivityService.broadcastSnapshot(roomId);
}
private void handlePing(Long roomId, String sessionId, LoginUser loginUser) {
@ -204,10 +266,13 @@ public class RoomWebSocketController {
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", msg);
}
/** 群聊:广播给房间内所有人 */
/** 群聊:广播给房间内所有人(支持纯文本、图片 URL、或图片+说明文字) */
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;
String content = chatContentFromBody(body);
String imageUrl = chatImageUrlFromBody(body);
if (content.isEmpty() && imageUrl.isEmpty()) return;
String messageType = resolveChatMessageType(body, imageUrl);
RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername());
Map<String, Object> sender = new HashMap<>();
@ -221,6 +286,10 @@ public class RoomWebSocketController {
msg.put("type", TYPE_CHAT);
msg.put("sender", sender);
msg.put("content", content);
msg.put("messageType", messageType);
if (!imageUrl.isEmpty()) {
msg.put("imageUrl", imageUrl);
}
msg.put("timestamp", System.currentTimeMillis());
roomChatService.saveGroupChat(roomId, loginUser.getUserId(), msg);
@ -231,8 +300,13 @@ public class RoomWebSocketController {
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;
if (targetUserNameObj == null) return;
String content = chatContentFromBody(body);
String imageUrl = chatImageUrlFromBody(body);
if (content.isEmpty() && imageUrl.isEmpty()) return;
String messageType = resolveChatMessageType(body, imageUrl);
String targetUserName = String.valueOf(targetUserNameObj);
if (targetUserName.equals(loginUser.getUsername())) return;
@ -255,6 +329,10 @@ public class RoomWebSocketController {
msg.put("targetUserId", targetUserIdObj);
msg.put("targetUserName", targetUserName);
msg.put("content", content);
msg.put("messageType", messageType);
if (!imageUrl.isEmpty()) {
msg.put("imageUrl", imageUrl);
}
msg.put("timestamp", System.currentTimeMillis());
Long targetUserId = targetUserIdObj instanceof Number ? ((Number) targetUserIdObj).longValue() : null;
@ -323,6 +401,48 @@ public class RoomWebSocketController {
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 聊天正文:不做 trim,保留用户空格;过滤 JSON null */
private static String chatContentFromBody(Map<String, Object> body) {
if (body == null || !body.containsKey("content")) {
return "";
}
Object v = body.get("content");
if (v == null) {
return "";
}
String s = String.valueOf(v);
return "null".equals(s) ? "" : s;
}
/** 聊天图片相对或绝对 URL */
private static String chatImageUrlFromBody(Map<String, Object> body) {
if (body == null || !body.containsKey("imageUrl")) {
return "";
}
Object v = body.get("imageUrl");
if (v == null) {
return "";
}
String s = String.valueOf(v).trim();
if (s.isEmpty() || "null".equals(s)) {
return "";
}
return s;
}
private static String resolveChatMessageType(Map<String, Object> body, String imageUrl) {
if (body != null && body.containsKey("messageType") && body.get("messageType") != null) {
String t = String.valueOf(body.get("messageType")).trim();
if ("image".equalsIgnoreCase(t)) {
return "image";
}
if ("text".equalsIgnoreCase(t)) {
return "text";
}
}
return imageUrl.isEmpty() ? "text" : "image";
}
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();

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

@ -9,6 +9,7 @@ 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.RoomUserActivityService;
import com.ruoyi.websocket.service.RoomWebSocketService;
/**
@ -31,6 +32,9 @@ public class WebSocketDisconnectListener implements ApplicationListener<SessionD
@Autowired
private RoomOnlineMemberBroadcastService roomOnlineMemberBroadcastService;
@Autowired
private RoomUserActivityService roomUserActivityService;
@Override
public void onApplicationEvent(SessionDisconnectEvent event) {
String sessionId = event.getSessionId();
@ -47,5 +51,7 @@ public class WebSocketDisconnectListener implements ApplicationListener<SessionD
String topic = "/topic/room/" + info.getRoomId();
messagingTemplate.convertAndSend(topic, msg);
roomOnlineMemberBroadcastService.broadcastAfterMembershipChange(info.getRoomId());
roomUserActivityService.removeSession(info.getRoomId(), sessionId);
roomUserActivityService.broadcastSnapshot(info.getRoomId());
}
}

219
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomUserActivityService.java

@ -0,0 +1,219 @@
package com.ruoyi.websocket.service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
/**
* 房间内用户当前操作聚合正在编辑的对象地图选中项等内存存储随会话清理
*/
@Service
public class RoomUserActivityService {
private static final String TYPE_USER_ACTIVITIES = "USER_ACTIVITIES";
/** roomId -> sessionId -> activity row */
private final ConcurrentHashMap<Long, ConcurrentHashMap<String, Map<String, Object>>> byRoom =
new ConcurrentHashMap<>();
@Autowired
private SimpMessagingTemplate messagingTemplate;
public void onEditLock(Long roomId, String sessionId, Map<String, Object> editor,
String objectType, Object objectId, String objectLabel) {
if (roomId == null || sessionId == null || objectType == null || objectId == null) {
return;
}
String label = objectLabel != null ? objectLabel : "";
Map<String, Object> row = getOrCreateRow(roomId, sessionId, editor);
Map<String, Object> editing = new HashMap<>();
editing.put("objectType", objectType);
editing.put("objectId", objectId);
editing.put("objectLabel", label);
editing.put("startedAt", System.currentTimeMillis());
row.put("editing", editing);
}
public void onEditUnlock(Long roomId, String sessionId, String objectType, Object objectId) {
if (roomId == null || sessionId == null || objectType == null || objectId == null) {
return;
}
ConcurrentHashMap<String, Map<String, Object>> m = byRoom.get(roomId);
if (m == null) {
return;
}
Map<String, Object> row = m.get(sessionId);
if (row == null) {
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> editing = (Map<String, Object>) row.get("editing");
if (editing == null) {
return;
}
if (objectType.equals(String.valueOf(editing.get("objectType")))
&& Objects.equals(objectId, editing.get("objectId"))) {
row.remove("editing");
}
cleanupRow(roomId, sessionId, row, m);
}
public void onSelection(Long roomId, String sessionId, Map<String, Object> editor,
String summary, int count, List<Map<String, Object>> items) {
if (roomId == null || sessionId == null) {
return;
}
Map<String, Object> row = getOrCreateRow(roomId, sessionId, editor);
if (count <= 0 || items == null || items.isEmpty()) {
row.remove("selection");
} else {
Map<String, Object> sel = new HashMap<>();
sel.put("summary", summary != null ? summary : "");
sel.put("count", count);
sel.put("items", new ArrayList<>(items));
sel.put("updatedAt", System.currentTimeMillis());
row.put("selection", sel);
}
ConcurrentHashMap<String, Map<String, Object>> m = byRoom.get(roomId);
if (m != null) {
cleanupRow(roomId, sessionId, row, m);
}
}
public void removeSession(Long roomId, String sessionId) {
if (roomId == null || sessionId == null) {
return;
}
ConcurrentHashMap<String, Map<String, Object>> m = byRoom.get(roomId);
if (m != null) {
m.remove(sessionId);
if (m.isEmpty()) {
byRoom.remove(roomId);
}
}
}
public List<Map<String, Object>> snapshot(Long roomId) {
ConcurrentHashMap<String, Map<String, Object>> m = byRoom.get(roomId);
if (m == null || m.isEmpty()) {
return new ArrayList<>();
}
List<Map<String, Object>> out = new ArrayList<>();
for (Map<String, Object> row : m.values()) {
Map<String, Object> packed = packRowForClient(row);
if (packed != null) {
out.add(packed);
}
}
out.sort(Comparator.comparing(a -> editorSortKey(a.get("editor"))));
return out;
}
public void broadcastSnapshot(Long roomId) {
if (roomId == null) {
return;
}
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_USER_ACTIVITIES);
msg.put("activities", snapshot(roomId));
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
public void sendSnapshotToUser(Long roomId, String userName) {
if (roomId == null || userName == null || userName.isEmpty()) {
return;
}
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_USER_ACTIVITIES);
msg.put("activities", snapshot(roomId));
messagingTemplate.convertAndSendToUser(userName, "/queue/private", msg);
}
private static String editorSortKey(Object editorObj) {
if (!(editorObj instanceof Map)) {
return "";
}
Map<?, ?> ed = (Map<?, ?>) editorObj;
Object nn = ed.get("nickName");
return nn != null ? String.valueOf(nn) : "";
}
private Map<String, Object> getOrCreateRow(Long roomId, String sessionId, Map<String, Object> editor) {
ConcurrentHashMap<String, Map<String, Object>> m =
byRoom.computeIfAbsent(roomId, k -> new ConcurrentHashMap<>());
return m.compute(sessionId, (sid, old) -> {
if (old != null) {
if (editor != null) {
old.put("editor", new HashMap<>(editor));
}
return old;
}
Map<String, Object> row = new HashMap<>();
row.put("sessionId", sessionId);
if (editor != null) {
row.put("editor", new HashMap<>(editor));
}
return row;
});
}
private void cleanupRow(Long roomId, String sessionId, Map<String, Object> row,
ConcurrentHashMap<String, Map<String, Object>> m) {
boolean hasEdit = row.containsKey("editing");
boolean hasSel = false;
if (row.containsKey("selection")) {
Object selObj = row.get("selection");
if (selObj instanceof Map) {
Map<?, ?> sel = (Map<?, ?>) selObj;
Object c = sel.get("count");
int cnt = c instanceof Number ? ((Number) c).intValue() : 0;
if (cnt > 0) {
hasSel = true;
} else {
row.remove("selection");
}
} else {
row.remove("selection");
}
}
if (!hasEdit && !hasSel) {
m.remove(sessionId);
}
if (m.isEmpty()) {
byRoom.remove(roomId);
}
}
private Map<String, Object> packRowForClient(Map<String, Object> row) {
boolean hasEdit = row.containsKey("editing");
boolean hasSel = false;
if (row.containsKey("selection")) {
Object selObj = row.get("selection");
if (selObj instanceof Map) {
Map<?, ?> sel = (Map<?, ?>) selObj;
Object c = sel.get("count");
hasSel = c instanceof Number && ((Number) c).intValue() > 0;
}
}
if (!hasEdit && !hasSel) {
return null;
}
Map<String, Object> copy = new HashMap<>();
copy.put("sessionId", row.get("sessionId"));
copy.put("editor", row.get("editor"));
if (hasEdit) {
copy.put("editing", row.get("editing"));
}
if (hasSel) {
copy.put("selection", row.get("selection"));
}
return copy;
}
}

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

@ -30,6 +30,10 @@ public class ObjectOperationLog extends BaseEntity {
public static final String OBJ_WAYPOINT = "waypoint";
/** 对象类型:平台 */
public static final String OBJ_PLATFORM = "platform";
/** 对象类型:房间地图上摆放的平台实例(room_platform_icon) */
public static final String OBJ_ROOM_PLATFORM_ICON = "room_platform_icon";
/** 对象类型:地图平台实例在 Redis 中的样式(图标颜色、探测区/威力区等 PlatformStyleDTO) */
public static final String OBJ_ROOM_PLATFORM_ICON_STYLE = "room_platform_icon_style";
/** 主键 */
private Long id;

3
ruoyi-system/src/main/java/com/ruoyi/system/mapper/RoomPlatformIconMapper.java

@ -14,6 +14,9 @@ public interface RoomPlatformIconMapper {
int insertRoomPlatformIcon(RoomPlatformIcon roomPlatformIcon);
/** 按指定主键插入(删除回滚场景) */
int insertRoomPlatformIconWithId(RoomPlatformIcon roomPlatformIcon);
int updateRoomPlatformIcon(RoomPlatformIcon roomPlatformIcon);
int deleteRoomPlatformIconById(Long id);

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

@ -4,9 +4,11 @@ import java.util.List;
import java.util.concurrent.TimeUnit;
import com.ruoyi.system.domain.PlatformLib;
import com.ruoyi.system.domain.RoomPlatformIcon;
import com.ruoyi.system.domain.RouteWaypoints;
import com.ruoyi.system.domain.Routes;
import com.ruoyi.system.mapper.PlatformLibMapper;
import com.ruoyi.system.mapper.RoomPlatformIconMapper;
import com.ruoyi.system.mapper.RouteWaypointsMapper;
import com.ruoyi.system.mapper.RoutesMapper;
import org.springframework.beans.factory.annotation.Autowired;
@ -50,6 +52,9 @@ public class ObjectOperationLogServiceImpl implements IObjectOperationLogService
@Autowired
private PlatformLibMapper platformLibMapper;
@Autowired
private RoomPlatformIconMapper roomPlatformIconMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public void saveLog(ObjectOperationLog log) {
@ -85,8 +90,13 @@ public class ObjectOperationLogServiceImpl implements IObjectOperationLogService
if (ObjectOperationLog.TYPE_DELETE == opType) {
return rollbackDelete(log);
}
if (ObjectOperationLog.TYPE_UPDATE == opType && log.getSnapshotBefore() != null && !log.getSnapshotBefore().isEmpty()) {
return rollbackUpdate(log);
if (ObjectOperationLog.TYPE_UPDATE == opType) {
if (ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON_STYLE.equals(log.getObjectType())) {
return rollbackRoomPlatformIconStyleUpdate(log);
}
if (log.getSnapshotBefore() != null && !log.getSnapshotBefore().isEmpty()) {
return rollbackUpdate(log);
}
}
return false;
}
@ -98,6 +108,9 @@ public class ObjectOperationLogServiceImpl implements IObjectOperationLogService
if (ObjectOperationLog.OBJ_PLATFORM.equals(objectType)) {
return rollbackPlatformUpdate(log);
}
if (ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON.equals(objectType)) {
return rollbackRoomPlatformIconUpdate(log);
}
return false;
}
@ -109,6 +122,12 @@ public class ObjectOperationLogServiceImpl implements IObjectOperationLogService
if (ObjectOperationLog.OBJ_PLATFORM.equals(objectType)) {
return rollbackPlatformReinsert(log);
}
if (ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON.equals(objectType)) {
return rollbackRoomPlatformIconReinsert(log);
}
if (ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON_STYLE.equals(objectType)) {
return rollbackRoomPlatformIconStyleReinsert(log);
}
return false;
}
@ -140,6 +159,15 @@ public class ObjectOperationLogServiceImpl implements IObjectOperationLogService
return true;
}
}
if (ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON.equals(objectType)) {
RoomPlatformIcon icon = JSON.parseObject(log.getSnapshotAfter(), RoomPlatformIcon.class);
if (icon != null && icon.getId() != null) {
cleanupRoomPlatformIconRedis(icon.getRoomId(), icon.getId());
roomPlatformIconMapper.deleteRoomPlatformIconById(icon.getId());
invalidateRoomCache(log.getRoomId());
return true;
}
}
return false;
}
@ -211,6 +239,70 @@ public class ObjectOperationLogServiceImpl implements IObjectOperationLogService
return true;
}
private boolean rollbackRoomPlatformIconUpdate(ObjectOperationLog log) {
RoomPlatformIcon before = JSON.parseObject(log.getSnapshotBefore(), RoomPlatformIcon.class);
if (before == null || before.getId() == null) return false;
roomPlatformIconMapper.updateRoomPlatformIcon(before);
invalidateRoomCache(log.getRoomId());
return true;
}
private boolean rollbackRoomPlatformIconReinsert(ObjectOperationLog log) {
RoomPlatformIcon before = JSON.parseObject(log.getSnapshotBefore(), RoomPlatformIcon.class);
if (before == null || before.getId() == null) return false;
RoomPlatformIcon existed = roomPlatformIconMapper.selectRoomPlatformIconById(before.getId());
if (existed == null) {
roomPlatformIconMapper.insertRoomPlatformIconWithId(before);
}
invalidateRoomCache(log.getRoomId());
return true;
}
private void cleanupRoomPlatformIconRedis(Long roomId, Long iconId) {
if (roomId == null || iconId == null) return;
String key = "room:" + roomId + ":platformIcons:platforms";
redisTemplate.opsForHash().delete(key, String.valueOf(iconId));
String oldKey = "room:" + roomId + ":route:0:platforms";
redisTemplate.opsForHash().delete(oldKey, String.valueOf(iconId));
}
/**
* 回滚样式修改恢复 snapshotBefore Redis为空则清除该实例样式
*/
private boolean rollbackRoomPlatformIconStyleUpdate(ObjectOperationLog log) {
Long roomId = log.getRoomId();
if (roomId == null || log.getObjectId() == null) return false;
long instanceId = Long.parseLong(log.getObjectId());
String field = String.valueOf(instanceId);
String key = "room:" + roomId + ":platformIcons:platforms";
String oldKey = "room:" + roomId + ":route:0:platforms";
String before = log.getSnapshotBefore();
if (before == null || before.isEmpty()) {
redisTemplate.opsForHash().delete(key, field);
redisTemplate.opsForHash().delete(oldKey, field);
} else {
redisTemplate.opsForHash().put(key, field, before);
redisTemplate.opsForHash().delete(oldKey, field);
}
invalidateRoomCache(roomId);
return true;
}
/** 回滚「样式删除」:用 snapshotBefore 写回 Redis */
private boolean rollbackRoomPlatformIconStyleReinsert(ObjectOperationLog log) {
if (log.getSnapshotBefore() == null || log.getSnapshotBefore().isEmpty()) return false;
Long roomId = log.getRoomId();
if (roomId == null || log.getObjectId() == null) return false;
long instanceId = Long.parseLong(log.getObjectId());
String field = String.valueOf(instanceId);
String key = "room:" + roomId + ":platformIcons:platforms";
redisTemplate.opsForHash().put(key, field, log.getSnapshotBefore());
String oldKey = "room:" + roomId + ":route:0:platforms";
redisTemplate.opsForHash().delete(oldKey, field);
invalidateRoomCache(roomId);
return true;
}
private void invalidateRoomCache(Long roomId) {
if (roomId == null) return;
String key = REDIS_KEY_PREFIX + roomId;

7
ruoyi-system/src/main/resources/mapper/system/RoomPlatformIconMapper.xml

@ -72,6 +72,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</trim>
</insert>
<insert id="insertRoomPlatformIconWithId" parameterType="com.ruoyi.system.domain.RoomPlatformIcon">
insert into room_platform_icon
(id, room_id, platform_id, platform_name, platform_type, icon_url, lng, lat, heading, icon_scale, sort_order, create_time, update_time)
values
(#{id}, #{roomId}, #{platformId}, #{platformName}, #{platformType}, #{iconUrl}, #{lng}, #{lat}, #{heading}, #{iconScale}, #{sortOrder}, #{createTime}, #{updateTime})
</insert>
<update id="updateRoomPlatformIcon" parameterType="com.ruoyi.system.domain.RoomPlatformIcon">
update room_platform_icon
<trim prefix="SET" suffixOverrides=",">

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

@ -150,6 +150,12 @@ export default {
selectedObject: 'Selected Object',
selectedCount: 'Selected Count',
unit: ' items',
colUser: 'User',
noActiveEditing: 'No one is editing an object',
noActiveSelection: 'No map selection reported by members',
typeRoute: 'Route',
typePlatform: 'Platform',
typeWaypoint: 'Waypoint',
objectOperationLogs: 'Object-level Operation Logs',
rollbackOperation: 'Rollback Operation',
rollbackConfirm: 'Operation Rollback Confirmation',
@ -172,7 +178,10 @@ export default {
noChatableMembers: 'No members available for private chat',
confirmPickContact: 'OK',
memberStatusOnline: 'Online',
memberStatusOffline: 'Offline'
memberStatusOffline: 'Offline',
sendImage: 'Send image',
imageUploadFailed: 'Image upload failed, please try again',
imageTooLarge: 'Image must be 5MB or smaller'
},
generateAirspace: {
title: 'Generate Airspace',

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

@ -150,6 +150,12 @@ export default {
selectedObject: '选中对象',
selectedCount: '选中数量',
unit: '个',
colUser: '用户',
noActiveEditing: '当前无人正在编辑对象',
noActiveSelection: '暂无成员选中地图对象',
typeRoute: '航线',
typePlatform: '平台',
typeWaypoint: '航点',
objectOperationLogs: '对象级操作日志',
rollbackOperation: '回滚操作',
rollbackConfirm: '操作回滚确认',
@ -172,7 +178,10 @@ export default {
noChatableMembers: '暂无可私聊的成员',
confirmPickContact: '确定',
memberStatusOnline: '在线',
memberStatusOffline: '离线'
memberStatusOffline: '离线',
sendImage: '发送图片',
imageUploadFailed: '图片上传失败,请重试',
imageTooLarge: '图片不能超过 5MB'
},
generateAirspace: {
title: '生成空域',

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

@ -294,22 +294,32 @@ export default {
.contextmenu {
margin: 0;
background: #fff;
z-index: 3000;
position: absolute;
list-style-type: none;
padding: 5px 0;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
padding: 8px 0;
min-width: 144px;
font-size: 13px;
font-weight: 500;
color: #333;
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Microsoft YaHei', sans-serif;
background: rgba(255, 255, 255, 0.72);
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
li {
margin: 0;
padding: 7px 16px;
padding: 9px 20px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: #eee;
background: rgba(22, 93, 255, 0.08);
color: #165dff;
}
}
}

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

@ -26,6 +26,7 @@ const WS_BASE = process.env.VUE_APP_BASE_API || '/dev-api'
* @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.onUserActivities - 房间用户当前操作快照 (activities: object[]) => {}
* @param {Function} options.onConnected - 连接成功回调
* @param {Function} options.onDisconnected - 断开回调
* @param {Function} options.onError - 错误回调
@ -49,6 +50,7 @@ export function createRoomWebSocket(options) {
onRoomState,
onObjectEditLock,
onObjectEditUnlock,
onUserActivities,
onConnected,
onDisconnected,
onError,
@ -97,22 +99,56 @@ export function createRoomWebSocket(options) {
}
}
function sendChat(content) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'CHAT', content })
})
}
/**
* @param {string|{content?: string, messageType?: string, imageUrl?: string}} contentOrPayload - 纯文本或含图片字段的对象
*/
function sendChat(contentOrPayload) {
if (!client || !client.connected) return
const payload =
typeof contentOrPayload === 'string'
? { type: 'CHAT', content: contentOrPayload }
: {
type: 'CHAT',
content: (contentOrPayload && contentOrPayload.content) || '',
...(contentOrPayload && contentOrPayload.messageType
? { messageType: contentOrPayload.messageType }
: {}),
...(contentOrPayload && contentOrPayload.imageUrl
? { imageUrl: contentOrPayload.imageUrl }
: {})
}
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify(payload)
})
}
function sendPrivateChat(targetUserId, targetUserName, content) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'PRIVATE_CHAT', targetUserId, targetUserName, content })
})
/**
* @param {string|{content?: string, messageType?: string, imageUrl?: string}} contentOrPayload
*/
function sendPrivateChat(targetUserId, targetUserName, contentOrPayload) {
if (!client || !client.connected) return
const isStr = typeof contentOrPayload === 'string'
const payload = {
type: 'PRIVATE_CHAT',
targetUserId,
targetUserName,
content: isStr ? contentOrPayload : (contentOrPayload && contentOrPayload.content) || '',
...(isStr
? {}
: {
...(contentOrPayload && contentOrPayload.messageType
? { messageType: contentOrPayload.messageType }
: {}),
...(contentOrPayload && contentOrPayload.imageUrl
? { imageUrl: contentOrPayload.imageUrl }
: {})
})
}
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify(payload)
})
}
function sendPrivateChatHistoryRequest(targetUserId) {
@ -170,13 +206,31 @@ export function createRoomWebSocket(options) {
}
/** 发送:当前成员锁定某对象进入编辑 */
function sendObjectEditLock(objectType, objectId) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'OBJECT_EDIT_LOCK', objectType, objectId })
})
function sendObjectEditLock(objectType, objectId, objectName) {
if (!client || !client.connected) return
const payload = { type: 'OBJECT_EDIT_LOCK', objectType, objectId }
if (objectName != null && String(objectName).trim() !== '') {
payload.objectName = String(objectName)
}
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify(payload)
})
}
/** 发送:当前地图选中(航线等),供「当前操作」展示 */
function sendUserSelection(selection) {
if (!client || !client.connected) return
const s = selection || {}
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({
type: 'USER_SELECTION',
summary: s.summary != null ? String(s.summary) : '',
selectedCount: s.selectedCount != null ? Number(s.selectedCount) : 0,
items: Array.isArray(s.items) ? s.items : []
})
})
}
/** 发送:当前成员解锁某对象(结束编辑) */
@ -227,6 +281,8 @@ export function createRoomWebSocket(options) {
onObjectEditLock && onObjectEditLock(body)
} else if (type === 'OBJECT_EDIT_UNLOCK' && body.objectType != null && body.objectId != null) {
onObjectEditUnlock && onObjectEditUnlock(body)
} else if (type === 'USER_ACTIVITIES' && Array.isArray(body.activities)) {
onUserActivities && onUserActivities(body.activities)
}
} catch (e) {
console.warn('[WebSocket] parse message error:', e)
@ -245,6 +301,8 @@ export function createRoomWebSocket(options) {
onPrivateChatHistory && onPrivateChatHistory(body.targetUserId, body.messages)
} else if (type === 'ROOM_STATE' && Array.isArray(body.visibleRouteIds)) {
onRoomState && onRoomState(body.visibleRouteIds)
} else if (type === 'USER_ACTIVITIES' && Array.isArray(body.activities)) {
onUserActivities && onUserActivities(body.activities)
}
} catch (e) {
console.warn('[WebSocket] parse private message error:', e)
@ -327,6 +385,7 @@ export function createRoomWebSocket(options) {
sendSyncPlatformStyles,
sendObjectEditLock,
sendObjectEditUnlock,
sendUserSelection,
get connected() {
return client && client.connected
}

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

File diff suppressed because it is too large

244
ruoyi-ui/src/views/cesiumMap/MenuGlyph.vue

@ -0,0 +1,244 @@
<template>
<span class="menu-glyph" aria-hidden="true">
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="menu-glyph-svg"
>
<g
stroke="currentColor"
stroke-width="1.25"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- 删除线框方樽式垃圾桶 -->
<g v-if="name === 'trash'">
<path d="M10 5V4a1 1 0 011-1h2a1 1 0 011 1v1" />
<path d="M5 7h14" />
<path d="M8 7l1 13h6l1-13" />
<path d="M10 11v6M14 11v6" />
</g>
<!-- 复制 -->
<g v-else-if="name === 'copy'">
<rect x="8" y="8" width="11" height="11" rx="1.5" />
<path d="M5 16V5a2 2 0 012-2h9" />
</g>
<!-- 切换选择 -->
<g v-else-if="name === 'switch'">
<path d="M5 9a7 7 0 0113-3" />
<path d="M5 9h4V5M19 15a7 7 0 01-13 3" />
<path d="M19 15h-4v4" />
</g>
<!-- 文档编辑 / 命名 -->
<g v-else-if="name === 'editDoc'">
<path d="M14 3h5v5" />
<path d="M4 20V13l9-9 5 5-9 9H4z" />
<path d="M12 6l6 6" />
</g>
<!-- 航点编辑罗盘+定位感 -->
<g v-else-if="name === 'waypoint'">
<circle cx="12" cy="11" r="3" />
<path d="M12 14v6M9 20h6" />
</g>
<!-- 速度表意简易仪表弧 -->
<g v-else-if="name === 'speed'">
<path d="M5 18a7 7 0 0114 0" />
<path d="M12 13v5" />
<circle cx="12" cy="18" r="1" fill="currentColor" stroke="none" />
</g>
<!-- 向前带柄箭头 -->
<g v-else-if="name === 'arrowLeft'">
<path d="m15 6-6 6 6 6M21 12H4" />
</g>
<!-- 向后 -->
<g v-else-if="name === 'arrowRight'">
<path d="m9 6 6 6-6 6M3 12h17" />
</g>
<!-- 循环 / 盘旋切换 -->
<g v-else-if="name === 'refresh'">
<path d="M21 12a9 9 0 01-9 9 5 5 0 01-5-5" />
<path d="M3 12a9 9 0 019-9 4 4 0 014 4" />
<path d="M3 7v5h5M21 17v-5h-5" />
</g>
<!-- 上锁 -->
<g v-else-if="name === 'lock'">
<rect x="6" y="10" width="12" height="10" rx="1.5" />
<path d="M8 10V7a4 4 0 018 0v3" />
</g>
<!-- 解锁 -->
<g v-else-if="name === 'unlock'">
<rect x="6" y="10" width="12" height="10" rx="1.5" />
<path d="M8 10V7a4 4 0 017-2" />
</g>
<!-- 拆分 -->
<g v-else-if="name === 'scissors'">
<circle cx="8" cy="8" r="2" />
<circle cx="8" cy="16" r="2" />
<path d="m14 6-6 6M14 18l-6-6" />
</g>
<!-- 拆分复制双文档 -->
<g v-else-if="name === 'docDuplicate'">
<path d="M8 4h8l2 2v12H8V4z" />
<path d="M6 8h8v12H6V8z" />
</g>
<!-- 播放 / 推演 -->
<g v-else-if="name === 'play'">
<path d="M9 7.5v9L18 12 9 7.5z" />
</g>
<!-- 标牌 -->
<g v-else-if="name === 'tag'">
<path d="M3 5a2 2 0 012-2h6l10 10-7 7-9-9V5z" />
<circle cx="8.5" cy="7.5" r="1" fill="currentColor" stroke="none" />
</g>
<!-- 铅笔通用编辑 -->
<g v-else-if="name === 'pencil'">
<path d="M12 20h9" />
<path d="m16.5 3.5 4.5 4.5L8 21H3v-5L16.5 3.5z" />
</g>
<!-- 探测扇形波束 -->
<g v-else-if="name === 'radar'">
<path d="M12 21c-4 0-7-3-7-7s3-7 7-7" />
<path d="M12 21V11" />
<path d="M12 11 6.5 6.5" />
<path d="M12 11 17.5 6.5" />
</g>
<!-- 威力同心圆靶 -->
<g v-else-if="name === 'target'">
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="4.5" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
</g>
<!-- 显示 -->
<g v-else-if="name === 'eye'">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z" />
<circle cx="12" cy="12" r="3" />
</g>
<!-- 隐藏 -->
<g v-else-if="name === 'eyeOff'">
<path d="M3 3l18 18" />
<path d="M10.6 10.6A3 3 0 0012 15a3 3 0 002.4-5.4" />
<path d="M9.9 5.1A10 10 0 0117.7 5c2.5 1 4.3 2.7 5.3 4M6.3 6.3C4.5 7.5 3 9.3 2 12c1.5 4 5.5 7 10 7 1.7 0 3.4-.4 4.9-1" />
</g>
<!-- 导弹简易三角体 -->
<g v-else-if="name === 'missile'">
<path d="M12 3l2 3H10l2-3z" />
<path d="M10 6h4l-1 10H11L10 6z" />
<path d="M9 16l-2 3M15 16l2 3" />
</g>
<!-- 填充几何分层方块 -->
<g v-else-if="name === 'fill'">
<rect x="5" y="5" width="10" height="10" rx="1" />
<path d="M5 14h10M5 11h10" />
<path d="M8 5v10" />
</g>
<!-- 线宽 / 标尺 -->
<g v-else-if="name === 'ruler'">
<path d="M4 16h16" />
<path d="M6 16V12M10 16v-6M14 16v-4M18 16v-8" />
</g>
<!-- 方位 -->
<g v-else-if="name === 'compass'">
<circle cx="12" cy="12" r="8" />
<path d="m12 8 2 4-2 6-2-6 2-4z" />
</g>
<!-- 透明度 / 蒙版意象画框+渐隐线 -->
<g v-else-if="name === 'opacity'">
<rect x="4" y="5" width="16" height="14" rx="1.5" />
<path d="M7 18c2-3 4-7 8-10" />
<path d="M7 14c2-2 4-5 7-7" />
</g>
<!-- 边框色双框 -->
<g v-else-if="name === 'border'">
<rect x="5" y="5" width="14" height="14" rx="2" />
<rect x="8.5" y="8.5" width="7" height="7" rx="1" />
</g>
<!-- 字号 T -->
<g v-else-if="name === 'type'">
<path d="M9 5h6M12 5v12" />
<path d="M8 17h8" />
</g>
<!-- 位置地理锚点十字+ -->
<g v-else-if="name === 'anchor'">
<circle cx="12" cy="12" r="3" />
<path d="M12 3v4M12 17v4M3 12h4M17 12h4" />
</g>
<!-- 伸缩框 -->
<g v-else-if="name === 'transform'">
<path d="M4 8V4h4M16 4h4v4M20 16v4h-4M8 20H4v-4" />
</g>
<!-- 圆点大小 -->
<g v-else-if="name === 'dot'">
<circle cx="12" cy="12" r="3" />
<circle cx="12" cy="12" r="7" />
</g>
<!-- 展开箭头 -->
<g v-else-if="name === 'chevronDown'">
<path d="m6 9 6 6 6-6" />
</g>
<g v-else-if="name === 'chevronRight'">
<path d="m9 6 6 6-6 6" />
</g>
<!-- 子项引导 -->
<g v-else-if="name === 'subDot'">
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
</g>
<!-- 色板 / 图标色滴状几何 -->
<g v-else-if="name === 'droplet'">
<path d="M12 21a5 5 0 005-5c0-3-5-9-5-9S7 13 7 16a5 5 0 005 5z" />
</g>
<!-- 颜色与大小滑杆+方块 -->
<g v-else-if="name === 'style'">
<rect x="5" y="6" width="6" height="6" rx="1" />
<path d="M15 8.5h4M15 12h4M15 15.5h4" />
</g>
<!-- 默认占位 -->
<circle v-else cx="12" cy="12" r="4" />
</g>
</svg>
</span>
</template>
<script>
export default {
name: 'MenuGlyph',
props: {
name: {
type: String,
required: true
},
small: {
type: Boolean,
default: false
}
}
}
</script>
<style scoped>
.menu-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
margin-right: 10px;
color: inherit;
vertical-align: middle;
}
.menu-glyph.is-small {
width: 14px;
height: 14px;
margin-right: 8px;
}
.menu-glyph-svg {
width: 18px;
height: 18px;
display: block;
}
.menu-glyph.is-small .menu-glyph-svg {
width: 14px;
height: 14px;
}
</style>

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

@ -12216,7 +12216,10 @@ export default {
} else if (this.getDrawingEntityTypes().includes(entityData.type)) {
this.notifyDrawingEntitiesChanged()
}
this.contextMenu.visible = false
//
if (property !== 'opacity') {
this.contextMenu.visible = false
}
}
},
@ -14623,6 +14626,11 @@ export default {
visible = false
}
// 线 DOM entity.show
if (this.whiteboardMode && (r.kind === 'platform' || r.kind === 'waypoint')) {
visible = false
}
if (!visible) {
items.push({
id,

25
ruoyi-ui/src/views/childRoom/LeftMenu.vue

@ -113,6 +113,11 @@ export default {
type: Array,
default: () => []
},
/** 与 index 中 defaultMenuItems 一致,用于从选择图标中恢复内置项时使用稳定 id */
defaultMenuItems: {
type: Array,
default: () => []
},
position: {
type: String,
default: 'left'
@ -175,18 +180,22 @@ export default {
},
confirmAddIcons(selectedItems) {
const defaults = this.defaultMenuItems || []
selectedItems.forEach(item => {
const newId = Date.now().toString() + Math.random().toString(36).substr(2, 9)
const newMenuItem = {
id: newId,
name: item.name,
icon: item.icon,
action: item.id
const preset = defaults.find(d => d.id === item.id)
if (preset) {
this.localMenuItems.push({ ...preset })
} else {
const newId = Date.now().toString() + Math.random().toString(36).substr(2, 9)
this.localMenuItems.push({
id: newId,
name: item.name,
icon: item.icon,
action: item.id
})
}
this.localMenuItems.push(newMenuItem)
})
this.$emit('update:menuItems', this.localMenuItems)
this.$emit('add-items', selectedItems)
},
quickDelete(item) {

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

@ -965,23 +965,28 @@ export default {
.sub-title-context-menu {
position: fixed;
z-index: 10000;
min-width: 120px;
padding: 4px 0;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.08);
min-width: 144px;
padding: 8px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Microsoft YaHei', sans-serif;
font-weight: 500;
color: #333;
background: rgba(255, 255, 255, 0.72);
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.sub-title-context-menu .context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
gap: 10px;
padding: 9px 20px;
font-size: 14px;
color: #1e293b;
color: #333;
cursor: pointer;
transition: background 0.2s;
transition: background 0.2s ease, color 0.2s ease;
}
.sub-title-context-menu .context-menu-item:hover {
@ -989,8 +994,12 @@ export default {
color: #165dff;
}
.sub-title-context-menu .context-menu-item-danger {
color: #f56c6c;
}
.sub-title-context-menu .context-menu-item-danger:hover {
background: rgba(239, 68, 68, 0.08);
color: #ef4444;
background: rgba(245, 108, 108, 0.1);
color: #f56c6c;
}
</style>

15
ruoyi-ui/src/views/childRoom/StepCanvasContent.vue

@ -1084,12 +1084,15 @@ export default {
position: fixed;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
gap: 8px;
padding: 8px 12px;
color: #333;
background: rgba(255, 255, 255, 0.72);
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
z-index: 10000;
margin-top: 4px;
}

16
ruoyi-ui/src/views/childRoom/TaskPageContent.vue

@ -921,17 +921,19 @@ export default {
}
/* Office 风格格式工具栏:仅选中文字右键时显示,固定定位在右键位置 */
.textbox-format-toolbar-fixed {
position: fixed;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
gap: 8px;
padding: 8px 12px;
color: #333;
background: rgba(255, 255, 255, 0.72);
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
z-index: 10000;
margin-top: 4px;
}

17
ruoyi-ui/src/views/childRoom/UnderstandingStepContent.vue

@ -1162,17 +1162,20 @@ export default {
pointer-events: auto;
}
/* 格式工具栏:仅选中文字右键时显示 */
/* 格式工具栏:与地图 ContextMenu / TopHeader 一致 */
.textbox-format-toolbar-fixed {
position: fixed;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
gap: 8px;
padding: 8px 12px;
color: #333;
background: rgba(255, 255, 255, 0.72);
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
z-index: 10000;
margin-top: 4px;
}

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

@ -181,14 +181,14 @@
:menu-items="menuItems"
:active-menu="activeMenu"
:is-edit-mode="isIconEditMode"
:available-icons="topNavItems"
:available-icons="iconPickerCatalog"
:default-menu-items="defaultMenuItems"
:position="menuPosition"
@hide="hideMenu"
@select="selectMenu"
@menu-action="handleMenuAction"
@update:menu-items="updateMenuItems"
@drag-end="handleMenuDragEnd"
@add-items="handleAddMenuItems"
@delete="handleDeleteMenuItem"
@save-menu-items="handleSaveMenuItems"
@exit-icon-edit="exitIconEdit"
@ -329,6 +329,7 @@
<online-members-dialog
v-model="showOnlineMembers"
:online-members="wsOnlineMembers"
:user-activities="roomUserActivities"
:room-id="currentRoomId"
:chat-messages="chatMessages"
:private-chat-messages="privateChatMessages"
@ -859,6 +860,9 @@ export default {
avatarRefreshTs: Date.now(),
/** 当前连接的 WebSocket sessionId 集合(用于过滤自己发出的同步消息,避免重复应用) */
mySyncSessionIds: [],
/** 房间内用户当前操作(编辑/选中),来自 USER_ACTIVITIES */
roomUserActivities: [],
_userSelectionPushTimer: null,
};
},
watch: {
@ -898,6 +902,15 @@ export default {
this.routeEditLockedId = null;
}
},
selectedRouteId() {
this.schedulePushUserSelection();
},
selectedRouteDetails: {
handler() {
this.schedulePushUserSelection();
},
deep: true
},
/** 打开甘特图抽屉时按方案加载颜色:同一方案取一种颜色(用该方案下第一条航线的平台色),不同方案不同颜色 */
showGanttDrawer(visible) {
if (!visible) return;
@ -1068,6 +1081,13 @@ export default {
if (editor && Number(editor.userId) !== Number(myId)) ids.push(Number(rid));
});
return ids;
},
/** 左侧「选择图标」:全部默认菜单样式 + 不与默认 id 重复的扩展项 */
iconPickerCatalog() {
const defs = this.defaultMenuItems || [];
const seen = new Set(defs.map(d => d.id));
const extras = (this.topNavItems || []).filter(t => !seen.has(t.id));
return [...defs, ...extras];
}
},
mounted() {
@ -2101,9 +2121,9 @@ export default {
this.getList();
this.getPlatformList();
},
sendChat(content) {
sendChat(contentOrPayload) {
if (this.wsConnection && this.wsConnection.sendChat) {
this.wsConnection.sendChat(content);
this.wsConnection.sendChat(contentOrPayload);
}
},
sendPrivateChatHistoryRequest(targetUserId) {
@ -2132,16 +2152,21 @@ export default {
const sep = fullUrl.includes('?') ? '&' : '?';
return `${fullUrl}${sep}t=${this.avatarRefreshTs}`;
},
sendPrivateChat(targetUserId, targetUserName, content) {
sendPrivateChat(targetUserId, targetUserName, contentOrPayload) {
if (!this.wsConnection || !this.wsConnection.sendPrivateChat) return;
this.wsConnection.sendPrivateChat(targetUserId, targetUserName, content);
this.wsConnection.sendPrivateChat(targetUserId, targetUserName, contentOrPayload);
const isStr = typeof contentOrPayload === 'string';
const content = isStr ? contentOrPayload : (contentOrPayload && contentOrPayload.content) || '';
const messageType = isStr ? 'text' : (contentOrPayload && contentOrPayload.messageType) || 'text';
const imageUrl = isStr ? undefined : contentOrPayload && contentOrPayload.imageUrl;
const sender = {
userId: this.currentUserId,
userName: this.$store.getters.name,
nickName: this.roomUserProfile.displayName || this.$store.getters.nickName || this.$store.getters.name,
avatar: this.roomUserProfile.avatar || ''
};
const msg = { type: 'PRIVATE_CHAT', sender, targetUserId, targetUserName, content, timestamp: Date.now() };
const msg = { type: 'PRIVATE_CHAT', sender, targetUserId, targetUserName, content, messageType, timestamp: Date.now() };
if (imageUrl) msg.imageUrl = imageUrl;
const list = this.privateChatMessages[targetUserId] || [];
this.$set(this.privateChatMessages, targetUserId, [...list, msg]);
},
@ -2167,6 +2192,7 @@ export default {
this.onlineCount = this.wsOnlineMembers.filter(x => x.roomId != null && String(x.roomId) === String(this.currentRoomId)).length;
const myId = this.$store.getters.id;
this.mySyncSessionIds = (members || []).filter(m => myId != null && String(m.userId) === String(myId)).map(m => m.sessionId).filter(Boolean);
this.syncMemberEditingFromActivities();
},
onMemberJoined: (member) => {
const baseUrl = (process.env.VUE_APP_BACKEND_URL || (window.location.origin + (process.env.VUE_APP_BASE_API || '')));
@ -2191,6 +2217,7 @@ export default {
this.mySyncSessionIds = [...this.mySyncSessionIds, member.sessionId];
}
}
this.syncMemberEditingFromActivities();
},
onMemberLeft: (member, sessionId) => {
this.wsOnlineMembers = this.wsOnlineMembers.filter(m => m.id !== sessionId && m.id !== member.sessionId);
@ -2212,6 +2239,9 @@ export default {
const cur = this.routeLockedBy[msg.objectId];
if (cur && cur.sessionId === msg.sessionId) this.$delete(this.routeLockedBy, msg.objectId);
},
onUserActivities: (activities) => {
this.applyUserActivitiesFromServer(activities);
},
onChatMessage: (msg) => {
this.chatMessages = [...this.chatMessages, msg];
},
@ -2256,7 +2286,9 @@ export default {
onRoomState: () => {
// 线
},
onConnected: () => {},
onConnected: () => {
setTimeout(() => this.pushUserSelectionNow(), 200);
},
onDisconnected: () => {
this.onlineCount = 0;
this.wsOnlineMembers = [];
@ -2264,6 +2296,7 @@ export default {
this.chatMessages = [];
this.privateChatMessages = {};
this.routeLockedBy = {};
this.roomUserActivities = [];
},
onError: (err) => {
console.warn('[WebSocket]', err);
@ -2281,6 +2314,57 @@ export default {
this.onlineCount = 0;
this.chatMessages = [];
this.privateChatMessages = {};
this.roomUserActivities = [];
},
applyUserActivitiesFromServer(activities) {
this.roomUserActivities = Array.isArray(activities) ? activities.map(a => ({ ...a })) : [];
this.syncMemberEditingFromActivities();
},
syncMemberEditingFromActivities() {
const editingSessions = new Set(
(this.roomUserActivities || [])
.filter(a => a && a.editing)
.map(a => a.sessionId)
.filter(Boolean)
);
if (!Array.isArray(this.wsOnlineMembers)) return;
this.wsOnlineMembers = this.wsOnlineMembers.map(m => ({
...m,
isEditing: editingSessions.has(m.id)
}));
},
buildUserSelectionPayload() {
const items = [];
if (this.selectedRouteId != null) {
let name = '航线';
const d = this.selectedRouteDetails;
if (d && String(d.id) === String(this.selectedRouteId)) {
name = d.callSign || d.name || name;
} else if (this.routes && this.routes.length) {
const r = this.routes.find(x => String(x.id) === String(this.selectedRouteId));
if (r) name = r.callSign || r.name || name;
}
items.push({
objectType: 'route',
objectId: this.selectedRouteId,
label: name
});
}
const summary = items.length ? items.map(i => i.label).join('、') : '';
return { summary, selectedCount: items.length, items };
},
schedulePushUserSelection() {
if (this._userSelectionPushTimer) clearTimeout(this._userSelectionPushTimer);
this._userSelectionPushTimer = setTimeout(() => {
this._userSelectionPushTimer = null;
this.pushUserSelectionNow();
}, 300);
},
pushUserSelectionNow() {
if (!this.wsConnection || !this.wsConnection.sendUserSelection) return;
if (!this.currentRoomId) return;
const payload = this.buildUserSelectionPayload();
this.wsConnection.sendUserSelection(payload);
},
/** 应用房间状态中的可见航线(新加入用户同步;若 routes 未加载则暂存) */
async applyRoomStateVisibleRoutes(visibleRouteIds) {
@ -2602,7 +2686,8 @@ export default {
this.showRouteDialog = true;
// 线广
if (this.wsConnection && this.wsConnection.sendObjectEditLock && route && route.id != null) {
this.wsConnection.sendObjectEditLock('route', route.id);
const routeLabel = route.name || route.callSign || '';
this.wsConnection.sendObjectEditLock('route', route.id, routeLabel);
this.routeEditLockedId = route.id;
}
},
@ -3788,19 +3873,6 @@ export default {
this.menuItems = newItems
},
handleAddMenuItems(selectedItems) {
selectedItems.forEach(item => {
const newId = Date.now().toString() + Math.random().toString(36).substr(2, 9)
const newMenuItem = {
id: newId,
name: item.name,
icon: item.icon,
action: item.id
}
this.menuItems.push(newMenuItem)
})
},
handleMenuAction(actionId) {
const actionMap = {
'savePlan': () => this.savePlan(),
@ -4494,43 +4566,32 @@ export default {
}
},
/** 加载当前用户的左侧菜单配置(登录且有过保存时生效) */
/** 加载当前用户的左侧菜单配置(以数据库为准,不自动补回已删除的默认项) */
async loadUserMenuConfig() {
try {
const res = await getMenuConfig()
const data = res && res.data
if (!data) return
if (data.menuItems) {
if (data.menuItems != null && data.menuItems !== '') {
let arr = []
try {
arr = typeof data.menuItems === 'string' ? JSON.parse(data.menuItems) : data.menuItems
} catch (e) { /* 解析失败保留默认 */ }
if (Array.isArray(arr) && arr.length > 0) {
const defaultMap = (this.defaultMenuItems || []).reduce((m, it) => { m[it.id] = it; return m }, {})
const savedIds = new Set(arr.map(i => i.id))
// 4T defaultMenuItems
const defaultOrder = (this.defaultMenuItems || []).map(d => d.id)
defaultOrder.forEach(defId => {
if (!savedIds.has(defId) && defaultMap[defId]) {
const insertAfterId = defaultOrder[defaultOrder.indexOf(defId) - 1]
const refIdx = insertAfterId ? arr.findIndex(i => i.id === insertAfterId) : -1
const insertIdx = refIdx >= 0 ? refIdx + 1 : 0
arr.splice(insertIdx, 0, { ...defaultMap[defId] })
savedIds.add(defId)
}
})
this.menuItems = arr.map(item => {
const def = defaultMap[item.id]
if (def) return { ...item, name: def.name, icon: def.icon, action: def.action }
return item
})
} catch (e) {
return
}
if (!Array.isArray(arr)) return
const defaultMap = (this.defaultMenuItems || []).reduce((m, it) => { m[it.id] = it; return m }, {})
this.menuItems = arr.map(item => {
const def = defaultMap[item.id]
if (def) return { ...item, name: def.name, icon: def.icon, action: def.action }
return item
})
}
if (data.position && ['left', 'top', 'bottom'].includes(data.position)) {
this.menuPosition = data.position
}
} catch (e) {
// 使
// 使 mounted
}
},
@ -7176,7 +7237,8 @@ export default {
this.routeEditInitialTab = 'waypoints';
this.showRouteDialog = true;
if (this.wsConnection && this.wsConnection.sendObjectEditLock && route && route.id != null) {
this.wsConnection.sendObjectEditLock('route', route.id);
const routeLabel = route.name || route.callSign || '';
this.wsConnection.sendObjectEditLock('route', route.id, routeLabel);
this.routeEditLockedId = route.id;
}
this.$message.info('请根据建议在航点列表中修改(如加入盘旋或调整相对K时/速度)');

18
ruoyi-ui/src/views/dialogs/IconSelectDialog.vue

@ -16,7 +16,8 @@
:class="{ selected: selectedIcons.includes(item.id) }"
@click="toggleIcon(item)"
>
<i :class="item.icon"></i>
<svg-icon v-if="isSvgIcon(item.icon)" :icon-class="item.icon" class="icon-select-svg" />
<i v-else :class="item.icon"></i>
<span class="icon-name">{{ item.name }}</span>
<div v-if="selectedIcons.includes(item.id)" class="check-icon">
<i class="el-icon-check"></i>
@ -72,6 +73,9 @@ export default {
}
},
methods: {
isSvgIcon(icon) {
return icon && typeof icon === 'string' && !icon.startsWith('el-icon-')
},
toggleIcon(item) {
const index = this.selectedIcons.indexOf(item.id)
if (index > -1) {
@ -143,15 +147,23 @@ export default {
background: rgba(22, 93, 255, 0.1);
}
.icon-item i {
.icon-item i,
.icon-item .icon-select-svg {
font-size: 28px;
color: #555;
margin-bottom: 8px;
transition: color 0.3s;
}
.icon-item .icon-select-svg {
width: 28px;
height: 28px;
}
.icon-item:hover i,
.icon-item.selected i {
.icon-item:hover .icon-select-svg,
.icon-item.selected i,
.icon-item.selected .icon-select-svg {
color: #165dff;
}

374
ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue

@ -40,30 +40,44 @@
<div class="current-operation">
<div class="operation-section">
<h4>{{ $t('onlineMembersDialog.editStatus') }}</h4>
<div class="status-item">
<span class="status-label">{{ $t('onlineMembersDialog.currentEditor') }}</span>
<span class="status-value">{{ currentEditor || $t('onlineMembersDialog.none') }}</span>
</div>
<div class="status-item">
<span class="status-label">{{ $t('onlineMembersDialog.editingObject') }}</span>
<span class="status-value">{{ editingObject || $t('onlineMembersDialog.none') }}</span>
</div>
<div class="status-item">
<span class="status-label">{{ $t('onlineMembersDialog.editingTime') }}</span>
<span class="status-value">{{ editingTime || $t('onlineMembersDialog.none') }}</span>
</div>
<div v-if="!activityRowsEditing.length" class="activity-empty">{{ $t('onlineMembersDialog.noActiveEditing') }}</div>
<table v-else class="activity-table">
<thead>
<tr>
<th>{{ $t('onlineMembersDialog.colUser') }}</th>
<th>{{ $t('onlineMembersDialog.editingObject') }}</th>
<th>{{ $t('onlineMembersDialog.editingTime') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in activityRowsEditing" :key="(row.sessionId || '') + '-e'">
<td>{{ activityUserName(row) }}</td>
<td>{{ formatEditingObjectLabel(row) }}</td>
<td class="activity-duration">{{ formatEditingDuration(row) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="operation-section">
<h4>{{ $t('onlineMembersDialog.selectStatus') }}</h4>
<div class="status-item">
<span class="status-label">{{ $t('onlineMembersDialog.selectedObject') }}</span>
<span class="status-value">{{ selectedObject || $t('onlineMembersDialog.none') }}</span>
</div>
<div class="status-item">
<span class="status-label">{{ $t('onlineMembersDialog.selectedCount') }}</span>
<span class="status-value">{{ selectedCount }} {{ $t('onlineMembersDialog.unit') }}</span>
</div>
<div v-if="!activityRowsSelection.length" class="activity-empty">{{ $t('onlineMembersDialog.noActiveSelection') }}</div>
<table v-else class="activity-table">
<thead>
<tr>
<th>{{ $t('onlineMembersDialog.colUser') }}</th>
<th>{{ $t('onlineMembersDialog.selectedObject') }}</th>
<th>{{ $t('onlineMembersDialog.selectedCount') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in activityRowsSelection" :key="(row.sessionId || '') + '-s'">
<td>{{ activityUserName(row) }}</td>
<td>{{ selectionSummaryText(row) }}</td>
<td>{{ selectionCountText(row) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@ -215,7 +229,18 @@
<span class="message-time">{{ formatMessageTime(message.timestamp) }}</span>
</div>
</div>
<div class="message-bubble">{{ message.content }}</div>
<div class="message-bubble">
<template v-if="isChatImageMessage(message)">
<img
:src="resolveChatContentImageUrl(message.imageUrl)"
class="chat-msg-image"
alt=""
@click="openChatImagePreview(resolveChatContentImageUrl(message.imageUrl))"
/>
<div v-if="message.content" class="chat-msg-caption">{{ message.content }}</div>
</template>
<template v-else>{{ message.content }}</template>
</div>
</div>
</template>
<template v-else-if="chatMode === 'private' && !privateChatTarget">
@ -264,12 +289,39 @@
<span class="message-time">{{ formatMessageTime(message.timestamp) }}</span>
</div>
</div>
<div class="message-bubble">{{ message.content }}</div>
<div class="message-bubble">
<template v-if="isChatImageMessage(message)">
<img
:src="resolveChatContentImageUrl(message.imageUrl)"
class="chat-msg-image"
alt=""
@click="openChatImagePreview(resolveChatContentImageUrl(message.imageUrl))"
/>
<div v-if="message.content" class="chat-msg-caption">{{ message.content }}</div>
</template>
<template v-else>{{ message.content }}</template>
</div>
</div>
</template>
</div>
<div class="chat-input-area">
<input
ref="chatImageInput"
type="file"
accept="image/*"
class="chat-image-file-input"
@change="onChatImageSelected"
/>
<el-button
type="default"
class="chat-image-btn"
:disabled="(chatMode === 'private' && !privateChatTarget) || chatImageUploading"
:loading="chatImageUploading"
icon="el-icon-picture-outline"
@click="triggerChatImagePick"
:title="$t('onlineMembersDialog.sendImage')"
/>
<el-input
v-model="newMessage"
class="chat-input-field"
@ -297,6 +349,7 @@
<script>
import { listObjectLog, rollbackObjectLog } from '@/api/system/objectLog'
import request from '@/utils/request'
const STORAGE_KEY_PREFIX = 'onlineMembersPanel_'
const OP_TYPE_MAP = { 1: '新增', 2: '修改', 3: '删除', 4: '选择', 5: '回滚' }
@ -342,6 +395,11 @@ export default {
currentUserId: {
type: [Number, String],
default: null
},
/** 来自 WebSocket 的房间用户当前操作(编辑/选中) */
userActivities: {
type: Array,
default: () => []
}
},
data() {
@ -365,13 +423,9 @@ export default {
{ id: 5, userId: 5, userName: 'sunqi', name: '孙七', role: '分析师', status: '在线', isEditing: false, avatar: 'https://cube.elemecdn.com/4/88/03b0d39583f48206768a7534e55bcpng.png' }
],
//
currentEditor: '张三',
editingObject: 'J-20 歼击机',
editingTime: 'K+00:45:23',
selectedObject: 'Alpha进场航线',
selectedCount: 1,
activityNow: Date.now(),
activityTickTimer: null,
//
operationLogs: [],
logTotal: 0,
@ -385,7 +439,8 @@ export default {
newMessage: '',
chatMode: 'group',
privateChatTarget: null,
privateContactPending: null
privateContactPending: null,
chatImageUploading: false
};
},
computed: {
@ -415,6 +470,18 @@ export default {
if (m) return m.name || m.userName || '';
return this.$t('onlineMembersDialog.selectMemberToChat');
},
activityRowsEditing() {
const list = this.userActivities || [];
return list.filter(a => a && a.editing);
},
activityRowsSelection() {
const list = this.userActivities || [];
return list.filter(a => {
if (!a || !a.selection) return false;
const n = Number(a.selection.count);
return !isNaN(n) && n > 0;
});
},
panelStyle() {
const pad = 16
const effW = Math.min(this.panelWidth, Math.max(320, window.innerWidth - pad))
@ -571,6 +638,8 @@ export default {
if (log.objectType === 'route') return '航线';
if (log.objectType === 'waypoint') return '航点';
if (log.objectType === 'platform') return '平台模板';
if (log.objectType === 'room_platform_icon') return '地图平台';
if (log.objectType === 'room_platform_icon_style') return '地图平台样式';
return log.objectType;
},
computeFieldChanges(log) {
@ -587,7 +656,12 @@ export default {
const keys = new Set();
if (before) Object.keys(before).forEach(k => keys.add(k));
if (after) Object.keys(after).forEach(k => keys.add(k));
const prefer = ['name', 'type', 'callSign', 'iconUrl', 'specsJson'];
const prefer = log.objectType === 'room_platform_icon'
? ['platformName', 'lng', 'lat', 'heading', 'iconScale', 'roomId', 'platformId', 'platformType', 'iconUrl', 'sortOrder']
: log.objectType === 'room_platform_icon_style'
? ['platformColor', 'labelFontColor', 'labelFontSize', 'platformSize', 'detectionZones', 'powerZones',
'detectionZoneRadius', 'detectionZoneColor', 'powerZoneRadius', 'powerZoneAngle', 'powerZoneColor']
: ['name', 'type', 'callSign', 'iconUrl', 'specsJson'];
const orderedKeys = [...prefer, ...[...keys].filter(k => !prefer.includes(k))];
orderedKeys.forEach(k => {
const bv = before ? before[k] : undefined;
@ -604,7 +678,27 @@ export default {
type: '类型',
callSign: '呼号',
iconUrl: '图标',
specsJson: '参数(JSON)'
specsJson: '参数(JSON)',
platformName: '平台名称',
lng: '经度',
lat: '纬度',
heading: '朝向(度)',
iconScale: '图标缩放',
roomId: '房间ID',
platformId: '平台库ID',
platformType: '平台类型',
sortOrder: '排序',
platformColor: '图标颜色',
labelFontColor: '标牌颜色',
labelFontSize: '标牌字号',
platformSize: '平台大小',
detectionZones: '探测区配置',
powerZones: '威力区配置',
detectionZoneRadius: '探测区半径(km)',
detectionZoneColor: '探测区颜色',
powerZoneRadius: '威力区半径(km)',
powerZoneAngle: '威力区夹角(度)',
powerZoneColor: '威力区颜色'
};
changes.push({
field: k,
@ -736,6 +830,66 @@ export default {
const el = this.$refs.chatContent;
if (el) el.scrollTop = el.scrollHeight;
},
isChatImageMessage(msg) {
return !!(msg && msg.imageUrl && String(msg.imageUrl).trim() && String(msg.imageUrl) !== 'null')
},
resolveChatContentImageUrl(url) {
if (!url) return ''
const s = String(url).trim()
if (!s || s === 'null') return ''
if (s.startsWith('http') || s.startsWith('data:') || s.startsWith('blob:')) return s
const baseUrl = process.env.VUE_APP_BACKEND_URL || (window.location.origin + (process.env.VUE_APP_BASE_API || ''))
return baseUrl + (s.startsWith('/') ? s : '/' + s)
},
openChatImagePreview(url) {
if (!url) return
window.open(url, '_blank', 'noopener,noreferrer')
},
triggerChatImagePick() {
if (this.chatMode === 'private' && !this.privateChatTarget) return
const input = this.$refs.chatImageInput
if (input) input.click()
},
async onChatImageSelected(ev) {
const input = ev && ev.target
const file = input && input.files && input.files[0]
if (input) input.value = ''
if (!file || !file.type || !file.type.startsWith('image/')) return
const maxBytes = 5 * 1024 * 1024
if (file.size > maxBytes) {
this.$message.warning(this.$t('onlineMembersDialog.imageTooLarge'))
return
}
if (this.chatMode === 'private' && !this.privateChatTarget) return
if (!this.sendChat || !this.sendPrivateChat) return
this.chatImageUploading = true
try {
const fd = new FormData()
fd.append('file', file)
const res = await request.post('/common/upload', fd)
if (res.code !== 200 || !res.url) {
throw new Error(res.msg || 'upload failed')
}
const caption = (this.newMessage || '').trim()
if (this.chatMode === 'group') {
this.sendChat({ content: caption, messageType: 'image', imageUrl: res.url })
} else {
const target = this.selectedPrivateMember
if (!target) return
this.sendPrivateChat(target.userId, target.userName, {
content: caption,
messageType: 'image',
imageUrl: res.url
})
}
this.newMessage = ''
this.$nextTick(() => this.scrollChatToBottom())
} catch (e) {
this.$message.error(this.$t('onlineMembersDialog.imageUploadFailed'))
} finally {
this.chatImageUploading = false
}
},
resolveAvatarUrl(av) {
if (!av) return '';
if (av.startsWith('http')) return av;
@ -779,6 +933,66 @@ export default {
clearPrivateChatTarget() {
this.privateChatTarget = null;
this.privateContactPending = null;
},
activityUserName(row) {
const e = row && row.editor ? row.editor : {};
return e.nickName || e.userName || this.$t('onlineMembersDialog.none');
},
objectTypeLabel(type) {
if (type === 'route') return this.$t('onlineMembersDialog.typeRoute');
if (type === 'platform') return this.$t('onlineMembersDialog.typePlatform');
if (type === 'waypoint') return this.$t('onlineMembersDialog.typeWaypoint');
return type || '';
},
formatEditingObjectLabel(row) {
const ed = row && row.editing ? row.editing : {};
const label = ed.objectLabel != null && String(ed.objectLabel).trim() !== '' ? String(ed.objectLabel) : '';
const t = this.objectTypeLabel(ed.objectType);
if (label && t) return `${t}${label}`;
if (label) return label;
return t || this.$t('onlineMembersDialog.none');
},
formatEditingDuration(row) {
const ed = row && row.editing ? row.editing : {};
const started = ed.startedAt;
if (started == null) return '—';
const sec = Math.max(0, Math.floor((this.activityNow - Number(started)) / 1000));
return this.formatElapsedSeconds(sec);
},
formatElapsedSeconds(totalSec) {
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const pad = n => String(n).padStart(2, '0');
if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
return `${pad(m)}:${pad(s)}`;
},
selectionSummaryText(row) {
const s = row && row.selection ? row.selection : {};
if (s.summary != null && String(s.summary).trim() !== '') return String(s.summary);
const items = Array.isArray(s.items) ? s.items : [];
if (!items.length) return this.$t('onlineMembersDialog.none');
return items.map(i => (i && i.label) || '').filter(Boolean).join('、') || this.$t('onlineMembersDialog.none');
},
selectionCountText(row) {
const s = row && row.selection ? row.selection : {};
const n = Number(s.count);
const c = isNaN(n) ? 0 : n;
return `${c} ${this.$t('onlineMembersDialog.unit')}`;
},
startActivityTickIfNeeded() {
if (!this.value || this.activeTab !== 'current') return;
if (this.activityTickTimer != null) return;
this.activityNow = Date.now();
this.activityTickTimer = setInterval(() => {
this.activityNow = Date.now();
}, 1000);
},
stopActivityTick() {
if (this.activityTickTimer != null) {
clearInterval(this.activityTickTimer);
this.activityTickTimer = null;
}
}
},
watch: {
@ -786,6 +1000,9 @@ export default {
if (val) {
this.loadPosition();
if (this.activeTab === 'logs') this.fetchOperationLogs();
this.startActivityTickIfNeeded();
} else {
this.stopActivityTick();
}
},
activeTab(newVal) {
@ -793,6 +1010,8 @@ export default {
if (newVal === 'chat') {
this.$nextTick(() => this.scrollChatToBottom());
}
if (newVal === 'current') this.startActivityTickIfNeeded();
else this.stopActivityTick();
},
chatMessages: {
handler() {
@ -822,6 +1041,9 @@ export default {
chatMode() {
this.privateContactPending = null;
}
},
beforeDestroy() {
this.stopActivityTick();
}
};
</script>
@ -1120,6 +1342,42 @@ export default {
font-weight: 500;
}
.activity-empty {
font-size: 13px;
color: #86909c;
padding: 8px 0;
}
.activity-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.activity-table th,
.activity-table td {
text-align: left;
padding: 8px 6px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
vertical-align: top;
}
.activity-table th {
color: #86909c;
font-weight: 600;
font-size: 12px;
}
.activity-table tbody tr:last-child td {
border-bottom: none;
}
.activity-duration {
white-space: nowrap;
color: #4e5969;
font-variant-numeric: tabular-nums;
}
/* 操作日志样式 */
.operation-logs {
position: relative;
@ -1653,6 +1911,56 @@ export default {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.chat-image-file-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.chat-image-btn {
flex-shrink: 0;
height: 44px;
width: 44px;
padding: 0;
border-radius: 8px;
border: 1px solid #e8eaed;
background: #f9fafb;
}
.chat-image-btn:hover:not(.is-disabled) {
border-color: var(--chat-primary);
color: var(--chat-primary);
}
.chat-msg-image {
display: block;
max-width: 220px;
max-height: 220px;
width: auto;
height: auto;
border-radius: 8px;
cursor: pointer;
vertical-align: top;
}
.chat-msg-caption {
margin-top: 8px;
font-size: 14px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.other-message .chat-msg-caption {
color: #1d2129;
}
.self-message .chat-msg-caption {
color: rgba(255, 255, 255, 0.95);
}
.chat-input-field {
flex: 1;
min-width: 0;

35
ruoyi-ui/src/views/selectRoom/index.vue

@ -573,41 +573,46 @@ export default {
color: #94a3b8;
}
/* 右键菜单 */
/* 右键菜单:与 TopHeader / ContextMenu 企业蓝一致 */
.context-menu {
position: fixed;
background: white;
border-radius: 10px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
padding: 6px 0;
min-width: 140px;
z-index: 2000;
border: 1px solid #f1f5f9;
min-width: 152px;
padding: 8px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Microsoft YaHei', sans-serif;
color: #333;
background: rgba(255, 255, 255, 0.72);
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
border: 1px solid rgba(22, 93, 255, 0.12);
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.menu-item {
padding: 10px 16px;
padding: 10px 20px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #475569;
transition: background 0.2s;
font-size: 14px;
font-weight: 500;
color: #333;
transition: background 0.2s ease, color 0.2s ease;
}
.menu-item:hover {
background-color: #f8fafc;
background: rgba(22, 93, 255, 0.08);
color: #165dff;
}
.menu-item-danger {
color: #ef4444;
color: #f56c6c;
}
.menu-item-danger:hover {
background-color: #fef2f2;
color: #ef4444;
background: rgba(245, 108, 108, 0.1);
color: #f56c6c;
}
/* --- Element UI 局部重写 --- */

Loading…
Cancel
Save