Browse Source

操作日志完善

ctw
cuitw 6 days ago
parent
commit
151efd679b
  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. 97
      ruoyi-ui/src/utils/websocket.js
  13. 23
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  14. 10
      ruoyi-ui/src/views/cesiumMap/index.vue
  15. 95
      ruoyi-ui/src/views/childRoom/index.vue
  16. 374
      ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue

64
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<String, Object> 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));

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

@ -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'
}
}

11
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'
}
}

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
}

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

10
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,

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

@ -328,6 +328,7 @@
<online-members-dialog
v-model="showOnlineMembers"
:online-members="wsOnlineMembers"
:user-activities="roomUserActivities"
:room-id="currentRoomId"
:chat-messages="chatMessages"
:private-chat-messages="privateChatMessages"
@ -849,6 +850,9 @@ export default {
avatarRefreshTs: Date.now(),
/** 当前连接的 WebSocket sessionId 集合(用于过滤自己发出的同步消息,避免重复应用) */
mySyncSessionIds: [],
/** 房间内用户当前操作(编辑/选中),来自 USER_ACTIVITIES */
roomUserActivities: [],
_userSelectionPushTimer: null,
};
},
watch: {
@ -888,6 +892,15 @@ export default {
this.routeEditLockedId = null;
}
},
selectedRouteId() {
this.schedulePushUserSelection();
},
selectedRouteDetails: {
handler() {
this.schedulePushUserSelection();
},
deep: true
},
/** 打开甘特图抽屉时按方案加载颜色:同一方案取一种颜色(用该方案下第一条航线的平台色),不同方案不同颜色 */
showGanttDrawer(visible) {
if (!visible) return;
@ -2033,9 +2046,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) {
@ -2064,16 +2077,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]);
},
@ -2099,6 +2117,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 || '')));
@ -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时/速度)');

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;

Loading…
Cancel
Save