diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomPlatformIconController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomPlatformIconController.java index e0b97b4..d702851 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomPlatformIconController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomPlatformIconController.java @@ -5,11 +5,14 @@ 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.system.domain.ObjectOperationLog; import com.ruoyi.system.domain.RoomPlatformIcon; +import com.ruoyi.system.service.IObjectOperationLogService; import com.ruoyi.system.service.IRoomPlatformIconService; /** @@ -25,6 +28,9 @@ public class RoomPlatformIconController extends BaseController { @Autowired private RedisTemplate redisTemplate; + @Autowired + private IObjectOperationLogService objectOperationLogService; + /** * 按房间ID查询该房间下所有地图平台图标(不分页) */ @@ -43,6 +49,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("新增失败"); } @@ -53,7 +76,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); } /** @@ -64,6 +109,23 @@ public class RoomPlatformIconController extends BaseController { @DeleteMapping("/{id}") public AjaxResult remove(@PathVariable Long id) { RoomPlatformIcon icon = roomPlatformIconService.selectById(id); + if (icon != null) { + try { + ObjectOperationLog opLog = new ObjectOperationLog(); + opLog.setRoomId(icon.getRoomId()); + opLog.setOperatorId(getUserId()); + opLog.setOperatorName(getUsername()); + opLog.setOperationType(ObjectOperationLog.TYPE_DELETE); + opLog.setObjectType(ObjectOperationLog.OBJ_ROOM_PLATFORM_ICON); + opLog.setObjectId(String.valueOf(id)); + opLog.setObjectName(icon.getPlatformName()); + opLog.setDetail("删除地图上的平台:" + (icon.getPlatformName() != null ? icon.getPlatformName() : "") + "(实例ID=" + id + ")"); + opLog.setSnapshotBefore(JSON.toJSONString(icon)); + objectOperationLogService.saveLog(opLog); + } catch (Exception e) { + logger.warn("记录房间地图平台删除操作日志失败", e); + } + } if (icon != null && icon.getRoomId() != null) { String key = "room:" + icon.getRoomId() + ":platformIcons:platforms"; redisTemplate.opsForHash().delete(key, String.valueOf(id)); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java index 01ea4e3..061fbd0 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java +++ b/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(); } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java index 57e9bb3..76dcd4a 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java +++ b/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"; /** * 处理房间消息:JOIN、LEAVE、PING、CHAT、PRIVATE_CHAT、SYNC_* @@ -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 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 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 body) { + RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername()); + Map 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> 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 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 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 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 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 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 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 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 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 body) { RoomUserProfile profile = roomUserProfileService.getOrInitByUser(loginUser.getUserId(), loginUser.getUsername()); RoomMemberDTO dto = new RoomMemberDTO(); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java index 3060c67..ad4fdf0 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java +++ b/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 sessionId -> activity row */ + private final ConcurrentHashMap>> byRoom = + new ConcurrentHashMap<>(); + + @Autowired + private SimpMessagingTemplate messagingTemplate; + + public void onEditLock(Long roomId, String sessionId, Map editor, + String objectType, Object objectId, String objectLabel) { + if (roomId == null || sessionId == null || objectType == null || objectId == null) { + return; + } + String label = objectLabel != null ? objectLabel : ""; + Map row = getOrCreateRow(roomId, sessionId, editor); + Map 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> m = byRoom.get(roomId); + if (m == null) { + return; + } + Map row = m.get(sessionId); + if (row == null) { + return; + } + @SuppressWarnings("unchecked") + Map editing = (Map) 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 editor, + String summary, int count, List> items) { + if (roomId == null || sessionId == null) { + return; + } + Map row = getOrCreateRow(roomId, sessionId, editor); + if (count <= 0 || items == null || items.isEmpty()) { + row.remove("selection"); + } else { + Map 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> 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> m = byRoom.get(roomId); + if (m != null) { + m.remove(sessionId); + if (m.isEmpty()) { + byRoom.remove(roomId); + } + } + } + + public List> snapshot(Long roomId) { + ConcurrentHashMap> m = byRoom.get(roomId); + if (m == null || m.isEmpty()) { + return new ArrayList<>(); + } + List> out = new ArrayList<>(); + for (Map row : m.values()) { + Map 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 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 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 getOrCreateRow(Long roomId, String sessionId, Map editor) { + ConcurrentHashMap> 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 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 row, + ConcurrentHashMap> 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 packRowForClient(Map 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 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; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/ObjectOperationLog.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/ObjectOperationLog.java index a97cc57..9d1e487 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/ObjectOperationLog.java +++ b/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; diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/RoomPlatformIconMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/RoomPlatformIconMapper.java index e6ffa3e..906d8e2 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/RoomPlatformIconMapper.java +++ b/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); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ObjectOperationLogServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ObjectOperationLogServiceImpl.java index f86bc86..8ff22ab 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ObjectOperationLogServiceImpl.java +++ b/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; diff --git a/ruoyi-system/src/main/resources/mapper/system/RoomPlatformIconMapper.xml b/ruoyi-system/src/main/resources/mapper/system/RoomPlatformIconMapper.xml index 286db42..bb03860 100644 --- a/ruoyi-system/src/main/resources/mapper/system/RoomPlatformIconMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/RoomPlatformIconMapper.xml @@ -72,6 +72,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + 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}) + + update room_platform_icon diff --git a/ruoyi-ui/src/lang/en.js b/ruoyi-ui/src/lang/en.js index 81781b2..af38a05 100644 --- a/ruoyi-ui/src/lang/en.js +++ b/ruoyi-ui/src/lang/en.js @@ -149,6 +149,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', @@ -171,6 +177,9 @@ 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' } } diff --git a/ruoyi-ui/src/lang/zh.js b/ruoyi-ui/src/lang/zh.js index 434c858..d733d4e 100644 --- a/ruoyi-ui/src/lang/zh.js +++ b/ruoyi-ui/src/lang/zh.js @@ -149,6 +149,12 @@ export default { selectedObject: '选中对象', selectedCount: '选中数量', unit: '个', + colUser: '用户', + noActiveEditing: '当前无人正在编辑对象', + noActiveSelection: '暂无成员选中地图对象', + typeRoute: '航线', + typePlatform: '平台', + typeWaypoint: '航点', objectOperationLogs: '对象级操作日志', rollbackOperation: '回滚操作', rollbackConfirm: '操作回滚确认', @@ -171,6 +177,9 @@ export default { noChatableMembers: '暂无可私聊的成员', confirmPickContact: '确定', memberStatusOnline: '在线', - memberStatusOffline: '离线' + memberStatusOffline: '离线', + sendImage: '发送图片', + imageUploadFailed: '图片上传失败,请重试', + imageTooLarge: '图片不能超过 5MB' } } diff --git a/ruoyi-ui/src/utils/websocket.js b/ruoyi-ui/src/utils/websocket.js index 6a4495c..4a8b4c0 100644 --- a/ruoyi-ui/src/utils/websocket.js +++ b/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 } diff --git a/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue b/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue index 53acf2e..98bb4e8 100644 --- a/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue +++ b/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue @@ -779,7 +779,23 @@ export default { return Math.min(100, Math.max(0, Math.round(Number(ed.opacity) * 100))) } }, + watch: { + visible(val) { + if (!val) this.resetInlinePickers() + } + }, methods: { + resetInlinePickers() { + this.showOpacityPicker = false + this.showColorPickerFor = null + this.showWidthPicker = false + this.showSizePicker = false + this.sizePickerType = '' + this.showFontSizePicker = false + this.showBearingTypeMenu = false + this.showRangingUnitMenu = false + this.expandedAddWaypoint = null + }, handleDelete() { this.$emit('delete') }, @@ -1213,13 +1229,18 @@ export default { } .menu-section { - margin-bottom: 12px; + margin-bottom: 6px; } .menu-section:last-child { margin-bottom: 0; } +/* 紧跟上一区块的分组标题,避免与上一项(如「删除」)留白过大 */ +.menu-section + .menu-section > .menu-title:first-child { + padding-top: 2px; +} + .menu-title { padding: 6px 20px 4px; font-size: 11px; diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 5d943f2..9695a3f 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -12156,7 +12156,10 @@ export default { } else if (this.getDrawingEntityTypes().includes(entityData.type)) { this.notifyDrawingEntitiesChanged() } - this.contextMenu.visible = false + // 透明度由右键菜单内滑块连续拖动更新;若此处关闭菜单,滑块会立刻卸载,无法调节且易留下异常状态 + if (property !== 'opacity') { + this.contextMenu.visible = false + } } }, @@ -14540,6 +14543,11 @@ export default { visible = false } + // 白板模式:航线标牌与航点名称走屏幕 DOM,不随 entity.show 一并隐藏,在此强制关闭 + if (this.whiteboardMode && (r.kind === 'platform' || r.kind === 'waypoint')) { + visible = false + } + if (!visible) { items.push({ id, diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index 2c89494..299f23d 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -328,6 +328,7 @@ 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 || ''))); @@ -2123,6 +2142,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); @@ -2144,6 +2164,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]; }, @@ -2188,7 +2211,9 @@ export default { onRoomState: () => { // 小房间内每人展示各自内容,新加入时不从他人同步可见航线 }, - onConnected: () => {}, + onConnected: () => { + setTimeout(() => this.pushUserSelectionNow(), 200); + }, onDisconnected: () => { this.onlineCount = 0; this.wsOnlineMembers = []; @@ -2196,6 +2221,7 @@ export default { this.chatMessages = []; this.privateChatMessages = {}; this.routeLockedBy = {}; + this.roomUserActivities = []; }, onError: (err) => { console.warn('[WebSocket]', err); @@ -2213,6 +2239,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) { @@ -2534,7 +2611,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; } }, @@ -7005,7 +7083,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时/速度)'); diff --git a/ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue b/ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue index d63270b..a6dc3aa 100644 --- a/ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue +++ b/ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue @@ -40,30 +40,44 @@

{{ $t('onlineMembersDialog.editStatus') }}

-
- {{ $t('onlineMembersDialog.currentEditor') }}: - {{ currentEditor || $t('onlineMembersDialog.none') }} -
-
- {{ $t('onlineMembersDialog.editingObject') }}: - {{ editingObject || $t('onlineMembersDialog.none') }} -
-
- {{ $t('onlineMembersDialog.editingTime') }}: - {{ editingTime || $t('onlineMembersDialog.none') }} -
+
{{ $t('onlineMembersDialog.noActiveEditing') }}
+ + + + + + + + + + + + + + + +
{{ $t('onlineMembersDialog.colUser') }}{{ $t('onlineMembersDialog.editingObject') }}{{ $t('onlineMembersDialog.editingTime') }}
{{ activityUserName(row) }}{{ formatEditingObjectLabel(row) }}{{ formatEditingDuration(row) }}
- +

{{ $t('onlineMembersDialog.selectStatus') }}

-
- {{ $t('onlineMembersDialog.selectedObject') }}: - {{ selectedObject || $t('onlineMembersDialog.none') }} -
-
- {{ $t('onlineMembersDialog.selectedCount') }}: - {{ selectedCount }} {{ $t('onlineMembersDialog.unit') }} -
+
{{ $t('onlineMembersDialog.noActiveSelection') }}
+ + + + + + + + + + + + + + + +
{{ $t('onlineMembersDialog.colUser') }}{{ $t('onlineMembersDialog.selectedObject') }}{{ $t('onlineMembersDialog.selectedCount') }}
{{ activityUserName(row) }}{{ selectionSummaryText(row) }}{{ selectionCountText(row) }}
@@ -215,7 +229,18 @@ {{ formatMessageTime(message.timestamp) }} -
{{ message.content }}
+
+ + +
+ + 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(); } }; @@ -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;